Micron Document
C.S.Burner 🪙 ━►🔥GIT Node


Displaying Raw • Download

meshchatx/src/frontend/components/map/MapPage.vue 9ec2a88817819d433c5d68dbec0ca070ca989b91 (9ec2a888) Text, 159.28 KB

T8b949e<!-- SPDX-License-Identifier: 0BSD -->

Tff7b72<templateTff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex flex-col h-full w-full bg-white dark:bg-zinc-950 overflow-hiddenTa5d6ff"Tff7b72>
T8b949e<!-- header -->
Tff7b72<div
Te6edf3class=Ta5d6ff"Ta5d6ffflex flex-col gap-2 sm:flex-row sm:items-center sm:gap-0 px-3 py-2 sm:px-4 border-b border-gray-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-900/80 backdrop-blur z-10 relativeTa5d6ff"
Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffhidden sm:flex items-center min-w-0 gap-2Ta5d6ff"Tff7b72>
Tff7b72<v-icon Te6edf3icon=Ta5d6ff"Ta5d6ffmdi-mapTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6fftext-blue-500 dark:text-blue-400 shrink-0Ta5d6ff" Te6edf3size=Ta5d6ff"Ta5d6ff24Ta5d6ff"Tff7b72>Tff7b72</v-icon>
Tff7b72<h1 Te6edf3class=Ta5d6ff"Ta5d6fftext-lg sm:text-xl font-black text-gray-900 dark:text-white truncateTa5d6ff"Tff7b72>
{{ Te6edf3$t("map.title") }}
Tff7b72</h1>
Tff7b72</div>

Tff7b72<div
Te6edf3class=Ta5d6ff"Ta5d6ffflex flex-wrap items-center gap-x-1.5 gap-y-2 sm:ml-auto sm:flex-nowrap sm:gap-2 sm:justify-end min-w-0Ta5d6ff"
Tff7b72>
T8b949e<!-- offline/online toggle -->
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex items-center bg-gray-100 dark:bg-zinc-800 rounded-lg p-0.5 sm:p-1 min-w-0 max-w-fullTa5d6ff"Tff7b72>
Tff7b72<button
Te6edf3:class=Ta5d6ff"
Ta5d6ff discoveredVisible
? Ta5d6ff'Ta5d6ffbg-white dark:bg-zinc-700 shadow-sm text-emerald-600 dark:text-emerald-400Ta5d6ff'
Ta5d6ff : Ta5d6ff'Ta5d6fftext-gray-500 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-zinc-700Ta5d6ff'
Ta5d6ff Ta5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffp-1.5 sm:p-2 rounded-lg transition-colors shrink-0Ta5d6ff"
Te6edf3:title=Ta5d6ff"Ta5d6ffdiscoveredVisible ? Ta5d6ff'Ta5d6ffHide Discovered InterfacesTa5d6ff'Ta5d6ff : Ta5d6ff'Ta5d6ffShow Discovered InterfacesTa5d6ff'Ta5d6ff"
Te6edf3:aria-pressed=Ta5d6ff"Ta5d6ffdiscoveredVisibleTa5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6fftoggleDiscoveredNodesTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffmap-marker-radiusTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-[18px] sm:size-5Ta5d6ff" Tff7b72/>
Tff7b72</button>
Tff7b72<button
Te6edf3:class=Ta5d6ff"
Ta5d6ff !offlineEnabled
? Ta5d6ff'Ta5d6ffbg-white dark:bg-zinc-700 shadow-sm text-blue-600 dark:text-blue-400Ta5d6ff'
Ta5d6ff : Ta5d6ff'Ta5d6fftext-gray-500 dark:text-gray-300Ta5d6ff'
Ta5d6ff Ta5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffpx-2 py-1 text-xs sm:px-3 sm:text-sm font-medium rounded-md transition-all shrink-0Ta5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6fftoggleOffline(false)Ta5d6ff"
Tff7b72>
{{ Te6edf3$t("map.online_mode") }}
Tff7b72</button>
Tff7b72<button
Te6edf3:class=Ta5d6ff"
Ta5d6ff offlineEnabled
? Ta5d6ff'Ta5d6ffbg-white dark:bg-zinc-700 shadow-sm text-blue-600 dark:text-blue-400Ta5d6ff'
Ta5d6ff : Ta5d6ff'Ta5d6fftext-gray-500 dark:text-gray-300Ta5d6ff'
Ta5d6ff Ta5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffpx-2 py-1 text-xs sm:px-3 sm:text-sm font-medium rounded-md transition-all shrink-0Ta5d6ff"
Te6edf3:disabled=Ta5d6ff"Ta5d6ff!hasOfflineMapTa5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6fftoggleOffline(true)Ta5d6ff"
Tff7b72>
{{ Te6edf3$t("map.offline_mode") }}
Tff7b72</button>
Tff7b72</div>

T8b949e<!-- upload: icon on mobile, full label from sm -->
Tff7b72<button
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffinline-flex items-center justify-center sm:gap-1 p-2 sm:px-3 sm:py-1.5 bg-blue-500 hover:bg-blue-600 text-white rounded-lg shadow-sm transition-colors text-sm font-medium shrink-0Ta5d6ff"
Te6edf3:title=Ta5d6ff"Te6edf3$tTa5d6ff(Ta5d6ff'Ta5d6ffmap.upload_mbtilesTa5d6ff'Ta5d6ff)Ta5d6ff"
@Te6edf3click=Ta5d6ff"Te6edf3$refs.fileInput.clickTa5d6ff()Ta5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffuploadTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-[18px] sm:size-4Ta5d6ff" Tff7b72/>
Tff7b72<span Te6edf3class=Ta5d6ff"Ta5d6ffhidden sm:inlineTa5d6ff"Tff7b72>{{ Te6edf3$t("map.upload_mbtiles") }}Tff7b72</span>
Tff7b72</button>
Tff7b72<input Te6edf3ref=Ta5d6ff"Ta5d6fffileInputTa5d6ff" Te6edf3type=Ta5d6ff"Ta5d6fffileTa5d6ff" Te6edf3accept=Ta5d6ff"Ta5d6ff.mbtilesTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffhiddenTa5d6ff" @Te6edf3change=Ta5d6ff"Ta5d6ffonFileSelectedTa5d6ff" Tff7b72/>

Tff7b72<button
Te6edf3v-if=Ta5d6ff"Ta5d6ff!isPopoutModeTa5d6ff"
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffhidden sm:flex p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-full transition-colors shrink-0Ta5d6ff"
Te6edf3:title=Ta5d6ff"Te6edf3$tTa5d6ff(Ta5d6ff'Ta5d6ffmap.pop_outTa5d6ff'Ta5d6ff)Ta5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffopenMapPopoutTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffopen-in-newTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-[18px] sm:size-5Ta5d6ff" Tff7b72/>
Tff7b72</button>
T8b949e<!-- search toggle (mobile only) -->
Tff7b72<button
Te6edf3v-if=Ta5d6ff"Ta5d6ff!offlineEnabledTa5d6ff"
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffsm:hidden p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-full transition-colors shrink-0Ta5d6ff"
Te6edf3:title=Ta5d6ff"Te6edf3$tTa5d6ff(Ta5d6ff'Ta5d6ffmap.search_placeholderTa5d6ff'Ta5d6ff)Ta5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6fftoggleMobileSearchTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3:icon-name=Ta5d6ff"Ta5d6ffisMobileSearchOpen ? Ta5d6ff'Ta5d6ffcloseTa5d6ff'Ta5d6ff : Ta5d6ff'Ta5d6ffmagnifyTa5d6ff'Ta5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-[18px]Ta5d6ff" Tff7b72/>
Tff7b72</button>
T8b949e<!-- settings button -->
Tff7b72<button
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffp-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-full transition-colors shrink-0Ta5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffisSettingsOpen = !isSettingsOpenTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffcogTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-[18px] sm:size-5Ta5d6ff" Tff7b72/>
Tff7b72</button>
Tff7b72</div>
Tff7b72</div>

T8b949e<!-- map container -->
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffrelative flex-1 min-h-0Ta5d6ff"Tff7b72>
Tff7b72<MapDrawingToolbar
Te6edf3:tools=Ta5d6ff"Ta5d6ffdrawingToolsTa5d6ff"
Te6edf3:draw-type=Ta5d6ff"Ta5d6ffdrawTypeTa5d6ff"
Te6edf3:measuring=Ta5d6ff"Ta5d6ffisMeasuringTa5d6ff"
Te6edf3:export-mode=Ta5d6ff"Ta5d6ffisExportModeTa5d6ff"
Te6edf3:selected-feature=Ta5d6ff"Ta5d6ffselectedFeatureTa5d6ff"
@Te6edf3toggle-draw=Ta5d6ff"Ta5d6fftoggleDrawTa5d6ff"
@Te6edf3toggle-export=Ta5d6ff"Ta5d6fftoggleExportModeTa5d6ff"
@Te6edf3toggle-measure=Ta5d6ff"Ta5d6fftoggleMeasureTa5d6ff"
@Te6edf3clear=Ta5d6ff"Ta5d6ffclearDrawingsTa5d6ff"
@Te6edf3edit-note=Ta5d6ff"Ta5d6ffstartEditingNoteTa5d6ff"
@Te6edf3delete-feature=Ta5d6ff"Ta5d6ffdeleteSelectedFeatureTa5d6ff"
@Te6edf3save=Ta5d6ff"Ta5d6ffshowSaveDrawingModal = trueTa5d6ff"
@Te6edf3load=Ta5d6ff"Ta5d6ffopenLoadDrawingModalTa5d6ff"
@Te6edf3locate=Ta5d6ff"Ta5d6ffgoToMyLocationTa5d6ff"
Tff7b72/>

Tff7b72<div
Te6edf3v-if=Ta5d6ff"Ta5d6ff!offlineEnabledTa5d6ff"
Te6edf3v-show=Ta5d6ff"Ta5d6ff!isMobileScreen || isMobileSearchOpenTa5d6ff"
Te6edf3ref=Ta5d6ff"Ta5d6ffsearchContainerTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffabsolute left-4 right-4 top-[calc(0.5rem+2.75rem+0.5rem)] z-30 sm:top-2 sm:left-auto sm:right-4 sm:w-80 md:max-lg:w-72 lg:w-80Ta5d6ff"
Tff7b72>
Tff7b72<MapSearchBar
Te6edf3v-model=Ta5d6ff"Ta5d6ffsearchQueryTa5d6ff"
Te6edf3:results=Ta5d6ff"Ta5d6ffsearchResultsTa5d6ff"
Te6edf3:error=Ta5d6ff"Ta5d6ffsearchErrorTa5d6ff"
Te6edf3:searching=Ta5d6ff"Ta5d6ffisSearchingTa5d6ff"
Te6edf3:show-results=Ta5d6ff"Ta5d6ffisSearchFocusedTa5d6ff"
@Te6edf3input=Ta5d6ff"Ta5d6ffonSearchInputTa5d6ff"
@Te6edf3search=Ta5d6ff"Ta5d6ffperformSearchTa5d6ff"
@Te6edf3clear=Ta5d6ff"Ta5d6ffclearSearchTa5d6ff"
@Te6edf3focus=Ta5d6ff"Ta5d6ffisSearchFocused = trueTa5d6ff"
@Te6edf3select=Ta5d6ff"Ta5d6ffselectSearchResultTa5d6ff"
Tff7b72/>
Tff7b72</div>

Tff7b72<div Te6edf3ref=Ta5d6ff"Ta5d6ffmapContainerTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffabsolute inset-0Ta5d6ff" Te6edf3:class=Ta5d6ff"Ta5d6ff{ Ta5d6ff'Ta5d6ffcursor-crosshairTa5d6ff'Ta5d6ff: isExportMode }Ta5d6ff"Tff7b72>Tff7b72</div>

T8b949e<!-- note hover tooltip -->
Tff7b72<div
Te6edf3v-if=Ta5d6ff"
Ta5d6ff hoveredFeature &&
(hoveredFeature.get(Ta5d6ff'Ta5d6ffnoteTa5d6ff'Ta5d6ff) ||
(hoveredFeature.get(Ta5d6ff'Ta5d6fftelemetryTa5d6ff'Ta5d6ff) && hoveredFeature.get(Ta5d6ff'Ta5d6fftelemetryTa5d6ff'Ta5d6ff).note)) &&
!editingFeature
Ta5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffabsolute pointer-events-none z-50 bg-white/90 dark:bg-zinc-900/90 backdrop-blur border border-gray-200 dark:border-zinc-700 rounded-lg shadow-xl p-2 text-sm text-gray-900 dark:text-zinc-100 max-w-xs transform -translate-x-1/2 -translate-y-full mb-4Ta5d6ff"
Te6edf3:style=Ta5d6ff"Ta5d6ff{
left: map.getPixelFromCoordinate(hoveredFeature.getGeometry().getCoordinates())[0] + Ta5d6ff'Ta5d6ffpxTa5d6ff'Ta5d6ff,
top: map.getPixelFromCoordinate(hoveredFeature.getGeometry().getCoordinates())[1] + Ta5d6ff'Ta5d6ffpxTa5d6ff'Ta5d6ff,
}Ta5d6ff"
Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6fffont-bold flex items-center gap-1 mb-1 text-amber-500Ta5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffnote-textTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
Tff7b72<spanTff7b72>{{
hoveredFeature.get("telemetry") ? hoveredFeature.get("peer")?.display_name || "Peer" : "Note"
}}Tff7b72</span>
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffwhitespace-pre-wrap break-wordsTa5d6ff"Tff7b72>
{{ hoveredFeature.get("note") || hoveredFeature.get("telemetry")?.note }}
Tff7b72</div>
Tff7b72</div>

T8b949e<!-- inline note editor (overlay) -->
Tff7b72<div Te6edf3ref=Ta5d6ff"Ta5d6ffnoteOverlayElementTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffabsolute z-40Ta5d6ff"Tff7b72>
Tff7b72<div
Te6edf3v-if=Ta5d6ff"Ta5d6ffeditingFeature && !isMobileScreenTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffbg-white dark:bg-zinc-900 rounded-xl shadow-2xl border border-gray-200 dark:border-zinc-700 p-4 w-64 transform -translate-x-1/2 -translate-y-full mb-6Ta5d6ff"
Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex items-center justify-between mb-2Ta5d6ff"Tff7b72>
Tff7b72<span Te6edf3class=Ta5d6ff"Ta5d6fftext-sm font-bold text-gray-900 dark:text-white flex items-center gap-1Ta5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffnote-editTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4 text-amber-500Ta5d6ff" Tff7b72/>
Edit Note
Tff7b72</span>
Tff7b72<button
Te6edf3class=Ta5d6ff"Ta5d6fftext-gray-400 hover:text-gray-600 dark:hover:text-zinc-300Ta5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffcloseNoteEditorTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffcloseTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
Tff7b72</button>
Tff7b72</div>
Tff7b72<textarea
Te6edf3v-model=Ta5d6ff"Ta5d6ffnoteTextTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffw-full h-24 p-2 text-sm bg-gray-50 dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded-lg focus:ring-2 focus:ring-amber-500 focus:border-transparent outline-none resize-none text-gray-900 dark:text-zinc-100Ta5d6ff"
Te6edf3placeholder=Ta5d6ff"Ta5d6ffType your note here...Ta5d6ff"
Tff7b72>Tff7b72</textarea>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex justify-between mt-3Ta5d6ff"Tff7b72>
Tff7b72<button
Te6edf3class=Ta5d6ff"Ta5d6ffpx-3 py-1.5 text-xs font-semibold text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors flex items-center gap-1Ta5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffdeleteNoteTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6fftrash-can-outlineTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-3.5Ta5d6ff" Tff7b72/>
Delete
Tff7b72</button>
Tff7b72<button
Te6edf3class=Ta5d6ff"Ta5d6ffpx-3 py-1.5 text-xs font-semibold bg-amber-500 text-white hover:bg-amber-600 rounded-lg shadow-sm transition-colorsTa5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffsaveNoteTa5d6ff"
Tff7b72>
Save
Tff7b72</button>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>

T8b949e<!-- context menu -->
Tff7b72<ContextMenuPanel
Te6edf3:show=Ta5d6ff"Ta5d6ffshowContextMenuTa5d6ff"
Te6edf3:x=Ta5d6ff"Ta5d6ffcontextMenuPos.xTa5d6ff"
Te6edf3:y=Ta5d6ff"Ta5d6ffcontextMenuPos.yTa5d6ff"
Te6edf3panel-class=Ta5d6ff"Ta5d6ffz-[120] overflow-hidden text-smTa5d6ff"
Tff7b72>
Tff7b72<template #headerTff7b72>
Tff7b72<div
Te6edf3class=Ta5d6ff"Ta5d6ffpx-3 py-2 font-semibold border-b border-gray-100 dark:border-zinc-800 text-gray-700 dark:text-zinc-200Ta5d6ff"
Tff7b72>
{{ contextMenuFeature ? "Feature actions" : "Map actions" }}
Tff7b72</div>
Tff7b72</template>
Tff7b72<ContextMenuItem Te6edf3v-if=Ta5d6ff"Ta5d6ffcontextMenuFeatureTa5d6ff" @Te6edf3click=Ta5d6ff"Ta5d6ffcontextSelectFeatureTa5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffcursor-defaultTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
Select / Move
Tff7b72</ContextMenuItem>
Tff7b72<ContextMenuItem Te6edf3v-if=Ta5d6ff"Ta5d6ffcontextMenuFeatureTa5d6ff" @Te6edf3click=Ta5d6ff"Ta5d6ffcontextAddNoteTa5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffnote-editTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
Add / Edit Note
Tff7b72</ContextMenuItem>
Tff7b72<ContextMenuItem
Te6edf3v-if=Ta5d6ff"Ta5d6ffcontextMenuFeature && !contextMenuFeature.get(Ta5d6ff'Ta5d6fftelemetryTa5d6ff'Ta5d6ff)Ta5d6ff"
Te6edf3item-class=Ta5d6ff"Ta5d6fftext-red-600 dark:text-red-400Ta5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffcontextDeleteFeatureTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffdeleteTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
Delete
Tff7b72</ContextMenuItem>
Tff7b72<ContextMenuItem @Te6edf3click=Ta5d6ff"Ta5d6ffcontextCopyCoordsTa5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffcrosshairs-gpsTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
Copy coords
Tff7b72</ContextMenuItem>
Tff7b72<ContextMenuItem Te6edf3v-if=Ta5d6ff"Ta5d6ff!contextMenuFeatureTa5d6ff" @Te6edf3click=Ta5d6ff"Ta5d6ffcontextClearMapTa5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffdelete-sweepTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
Clear drawings
Tff7b72</ContextMenuItem>
Tff7b72</ContextMenuPanel>

T8b949e<!-- loading skeleton for map -->
Tff7b72<div Te6edf3v-if=Ta5d6ff"Ta5d6ff!isMapLoadedTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffabsolute inset-0 z-0 bg-slate-100 dark:bg-zinc-900 animate-pulseTa5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffgrid grid-cols-4 grid-rows-4 h-full w-full gap-1 p-1 opacity-20Ta5d6ff"Tff7b72>
Tff7b72<div Te6edf3v-for=Ta5d6ff"Ta5d6ffi in 16Ta5d6ff" Te6edf3:key=Ta5d6ff"Ta5d6ffiTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffbg-slate-300 dark:bg-zinc-700 rounded-lgTa5d6ff"Tff7b72>Tff7b72</div>
Tff7b72</div>
Tff7b72</div>

Tff7b72<MapMarkerPanel
Te6edf3v-if=Ta5d6ff"Ta5d6ffselectedMarkerTa5d6ff"
Te6edf3:marker=Ta5d6ff"Ta5d6ffselectedMarkerTa5d6ff"
Te6edf3:mini-chat-open=Ta5d6ff"Ta5d6ffisMiniChatOpenTa5d6ff"
@Te6edf3close=Ta5d6ff"Ta5d6ffselectedMarker = nullTa5d6ff"
@Te6edf3toggle-tracking=Ta5d6ff"Ta5d6fftoggleTrackingTa5d6ff"
@Te6edf3toggle-mini-chat=Ta5d6ff"Ta5d6ffisMiniChatOpen = !isMiniChatOpenTa5d6ff"
Tff7b72/>

Tff7b72<MapClusterPanel
Te6edf3v-if=Ta5d6ff"Ta5d6ffselectedCluster && !selectedMarkerTa5d6ff"
Te6edf3:cluster=Ta5d6ff"Ta5d6ffselectedClusterTa5d6ff"
@Te6edf3close=Ta5d6ff"Ta5d6ffcloseClusterPanelTa5d6ff"
@Te6edf3select=Ta5d6ff"Ta5d6ffselectClusterItemTa5d6ff"
Tff7b72/>

Tff7b72<MapExportInstructions Te6edf3v-if=Ta5d6ff"Ta5d6ffisExportMode && !selectedBboxTa5d6ff" Tff7b72/>

Tff7b72<MapExportConfigPanel
Te6edf3v-if=Ta5d6ff"Ta5d6ffisExportMode && selectedBboxTa5d6ff"
Te6edf3v-model:min-zoom=Ta5d6ff"Ta5d6ffexportMinZoomTa5d6ff"
Te6edf3v-model:max-zoom=Ta5d6ff"Ta5d6ffexportMaxZoomTa5d6ff"
Te6edf3:estimated-tiles=Ta5d6ff"Ta5d6ffestimatedTilesTa5d6ff"
Te6edf3:exporting=Ta5d6ff"Ta5d6ffisExportingTa5d6ff"
@Te6edf3cancel=Ta5d6ff"Ta5d6ffcancelExportTa5d6ff"
@Te6edf3start=Ta5d6ff"Ta5d6ffstartExportTa5d6ff"
Tff7b72/>

Tff7b72<MapExportProgressPanel
Te6edf3v-if=Ta5d6ff"Ta5d6ffexportStatusTa5d6ff"
Te6edf3:status=Ta5d6ff"Ta5d6ffexportStatusTa5d6ff"
Te6edf3:export-id=Ta5d6ff"Ta5d6ffexportIdTa5d6ff"
@Te6edf3dismiss=Ta5d6ff"Ta5d6ffexportStatus = nullTa5d6ff"
@Te6edf3cancel=Ta5d6ff"Ta5d6ffcancelActiveExportTa5d6ff"
@Te6edf3show-offline-maps=Ta5d6ff"Ta5d6ffisSettingsOpen = trueTa5d6ff"
Tff7b72/>

Tff7b72<MapLoadingOverlay Te6edf3v-if=Ta5d6ff"Ta5d6ffisUploadingTa5d6ff" Tff7b72/>

Tff7b72<MapNoMapWarning Te6edf3v-if=Ta5d6ff"Ta5d6ffofflineEnabled && !hasOfflineMapTa5d6ff" @Te6edf3upload=Ta5d6ff"Te6edf3$refs.fileInput.clickTa5d6ff()Ta5d6ff" Tff7b72/>

T8b949e<!-- map info overlay -->
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffabsolute bottom-4 left-4 z-10 space-y-2 pointer-events-noneTa5d6ff"Tff7b72>
Tff7b72<div
Te6edf3v-if=Ta5d6ff"Ta5d6ffmetadata && metadata.name && !metadata.name.startsWith(Ta5d6ff'Ta5d6ffMap ExportTa5d6ff'Ta5d6ff)Ta5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffbg-white/80 dark:bg-zinc-900/80 backdrop-blur border border-gray-200 dark:border-zinc-800 p-2 rounded-lg text-xs text-gray-600 dark:text-zinc-400 pointer-events-auto shadow-smTa5d6ff"
Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6fffont-semibold text-gray-900 dark:text-zinc-100 mb-1Ta5d6ff"Tff7b72>
{{ metadata.name }}
Tff7b72</div>
Tff7b72<div
Te6edf3v-if=Ta5d6ff"Ta5d6ffmetadata.attributionTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffmax-w-xs overflow-hidden text-ellipsis whitespace-nowrapTa5d6ff"
Te6edf3:title=Ta5d6ff"Ta5d6ffmetadata.attributionTa5d6ff"
Tff7b72>
{{ metadata.attribution }}
Tff7b72</div>
Tff7b72</div>

T8b949e<!-- Lat/Lon Box -->
Tff7b72<div
Te6edf3class=Ta5d6ff"Ta5d6ffbg-white/80 dark:bg-zinc-900/80 backdrop-blur border border-gray-200 dark:border-zinc-800 p-2 rounded-lg text-[10px] font-mono text-gray-600 dark:text-zinc-400 pointer-events-auto shadow-sm flex flex-col space-y-0.5Ta5d6ff"
Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex justify-between space-x-4Ta5d6ff"Tff7b72>
Tff7b72<span Te6edf3class=Ta5d6ff"Ta5d6ffopacity-50 uppercase tracking-tighterTa5d6ff"Tff7b72>LatTff7b72</span>
Tff7b72<span Te6edf3class=Ta5d6ff"Ta5d6fftext-gray-900 dark:text-zinc-100Ta5d6ff"Tff7b72>{{ displayCoords[1].toFixed(6) }}Tff7b72</span>
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex justify-between space-x-4Ta5d6ff"Tff7b72>
Tff7b72<span Te6edf3class=Ta5d6ff"Ta5d6ffopacity-50 uppercase tracking-tighterTa5d6ff"Tff7b72>LonTff7b72</span>
Tff7b72<span Te6edf3class=Ta5d6ff"Ta5d6fftext-gray-900 dark:text-zinc-100Ta5d6ff"Tff7b72>{{ displayCoords[0].toFixed(6) }}Tff7b72</span>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>

// controls overlay -->
T8b949e<!-- controls overlay -->
Tff7b72<div
Te6edf3v-if=Ta5d6ff"Ta5d6ffisSettingsOpenTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffabsolute top-14 right-4 z-20 w-72 max-h-[calc(100vh-5rem)] bg-white/95 dark:bg-zinc-900/95 backdrop-blur-sm rounded-xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden flex flex-col animate-in fade-in zoom-in-95 duration-200Ta5d6ff"
Tff7b72>
Tff7b72<div
Te6edf3class=Ta5d6ff"Ta5d6ffp-3 border-b border-gray-200 dark:border-zinc-800 flex items-center justify-between shrink-0 bg-gray-50/50 dark:bg-zinc-800/50Ta5d6ff"
Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex items-center space-x-2Ta5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffcogTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4 text-gray-500 dark:text-gray-300Ta5d6ff" Tff7b72/>
Tff7b72<h3 Te6edf3class=Ta5d6ff"Ta5d6fffont-bold text-gray-900 dark:text-zinc-100 text-xs uppercase tracking-widestTa5d6ff"Tff7b72>
{{ Te6edf3$t("app.settings") }}
Tff7b72</h3>
Tff7b72</div>
Tff7b72<button
Te6edf3class=Ta5d6ff"Ta5d6ffp-1 hover:bg-gray-200 dark:hover:bg-zinc-700 rounded-lg transition-colors text-gray-500 dark:text-gray-300Ta5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffisSettingsOpen = falseTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffcloseTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
Tff7b72</button>
Tff7b72</div>

Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffp-3 space-y-4 overflow-y-auto scrollbar-thin flex-1Ta5d6ff"Tff7b72>
T8b949e<!-- Quick Actions -->
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffgrid grid-cols-2 gap-2Ta5d6ff"Tff7b72>
Tff7b72<button
Te6edf3class=Ta5d6ff"Ta5d6ffflex items-center justify-center space-x-1.5 px-2 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-all text-[10px] font-bold uppercase tracking-tight shadow-sm active:scale-95Ta5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffsetAsDefaultViewTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffpinTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-3Ta5d6ff" Tff7b72/>
Tff7b72<spanTff7b72>Set DefaultTff7b72</span>
Tff7b72</button>

Tff7b72<button
Te6edf3class=Ta5d6ff"Ta5d6ffflex items-center justify-center space-x-1.5 px-2 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-zinc-800 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-300 rounded-lg transition-all text-[10px] font-bold uppercase tracking-tight active:scale-95Ta5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffclearCacheTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6fftrash-can-outlineTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-3Ta5d6ff" Tff7b72/>
Tff7b72<spanTff7b72>Clear CacheTff7b72</span>
Tff7b72</button>
Tff7b72</div>

T8b949e<!-- Map Style Presets -->
Tff7b72<div Te6edf3v-if=Ta5d6ff"Ta5d6ff!offlineEnabledTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffspace-y-2Ta5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex items-center justify-betweenTa5d6ff"Tff7b72>
Tff7b72<label Te6edf3class=Ta5d6ff"Ta5d6fftext-[10px] font-bold text-gray-400 uppercase tracking-widestTa5d6ff"
Tff7b72>Map StylesTff7b72</label
>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffh-px flex-1 bg-gray-100 dark:bg-zinc-800 ml-3Ta5d6ff"Tff7b72>Tff7b72</div>
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffgrid grid-cols-4 gap-1Ta5d6ff"Tff7b72>
Tff7b72<button
Te6edf3v-for=Ta5d6ff"Ta5d6ffstyle in [
{ id: Ta5d6ff'Ta5d6ffosmTa5d6ff'Ta5d6ff, label: Ta5d6ff'Ta5d6ffOSMTa5d6ff'Ta5d6ff },
{ id: Ta5d6ff'Ta5d6ffcarto-darkTa5d6ff'Ta5d6ff, label: Ta5d6ff'Ta5d6ffDarkTa5d6ff'Ta5d6ff },
{ id: Ta5d6ff'Ta5d6ffcarto-voyagerTa5d6ff'Ta5d6ff, label: Ta5d6ff'Ta5d6ffVoyTa5d6ff'Ta5d6ff },
{ id: Ta5d6ff'Ta5d6ffcarto-lightTa5d6ff'Ta5d6ff, label: Ta5d6ff'Ta5d6ffLiteTa5d6ff'Ta5d6ff },
]Ta5d6ff"
Te6edf3:key=Ta5d6ff"Ta5d6ffstyle.idTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffpy-1.5 text-[8px] font-bold uppercase rounded-md transition-all border leading-tightTa5d6ff"
Te6edf3:class=Ta5d6ff"
Ta5d6ff (style.id === Ta5d6ff'Ta5d6ffosmTa5d6ff'Ta5d6ff && tileServerUrl.includes(Ta5d6ff'Ta5d6ffopenstreetmap.orgTa5d6ff'Ta5d6ff)) ||
(style.id === Ta5d6ff'Ta5d6ffcarto-darkTa5d6ff'Ta5d6ff &&
tileServerUrl.includes(Ta5d6ff'Ta5d6ffbasemaps.cartocdn.com/dark_allTa5d6ff'Ta5d6ff)) ||
(style.id === Ta5d6ff'Ta5d6ffcarto-voyagerTa5d6ff'Ta5d6ff && tileServerUrl.includes(Ta5d6ff'Ta5d6ffrastertiles/voyagerTa5d6ff'Ta5d6ff)) ||
(style.id === Ta5d6ff'Ta5d6ffcarto-lightTa5d6ff'Ta5d6ff &&
tileServerUrl.includes(Ta5d6ff'Ta5d6ffbasemaps.cartocdn.com/light_allTa5d6ff'Ta5d6ff))
? Ta5d6ff'Ta5d6ffbg-blue-500 border-blue-600 text-white shadow-sm ring-2 ring-blue-500/20Ta5d6ff'
Ta5d6ff : Ta5d6ff'Ta5d6ffbg-white dark:bg-zinc-900 border-gray-200 dark:border-zinc-800 text-gray-500 dark:text-zinc-400 hover:bg-gray-50 dark:hover:bg-zinc-800Ta5d6ff'
Ta5d6ff Ta5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffsetTileServer(style.id)Ta5d6ff"
Tff7b72>
{{ style.label }}
Tff7b72</button>
Tff7b72</div>

Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffspace-y-1Ta5d6ff"Tff7b72>
Tff7b72<label
Te6edf3class=Ta5d6ff"Ta5d6fftext-[9px] font-bold text-gray-500 dark:text-zinc-500 uppercase flex items-centerTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6fflink-variantTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-3 mr-1Ta5d6ff" Tff7b72/>
Tile Server URL
Tff7b72</label>
Tff7b72<input
Te6edf3v-model=Ta5d6ff"Ta5d6fftileServerUrlTa5d6ff"
Te6edf3type=Ta5d6ff"Ta5d6fftextTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffw-full bg-gray-50/50 dark:bg-zinc-950/50 border border-gray-200 dark:border-zinc-800 rounded-lg px-2 py-1.5 text-[10px] dark:text-zinc-100 font-mono focus:ring-1 focus:ring-blue-500 transition-all outline-noneTa5d6ff"
Te6edf3:placeholder=Ta5d6ff"Te6edf3$tTa5d6ff(Ta5d6ff'Ta5d6ffmap.tile_server_url_placeholderTa5d6ff'Ta5d6ff)Ta5d6ff"
@Te6edf3blur=Ta5d6ff"Ta5d6ffsaveTileServerUrlTa5d6ff"
Tff7b72/>
Tff7b72</div>

Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffspace-y-1Ta5d6ff"Tff7b72>
Tff7b72<label
Te6edf3class=Ta5d6ff"Ta5d6fftext-[9px] font-bold text-gray-500 dark:text-zinc-500 uppercase flex items-centerTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffmagnifyTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-3 mr-1Ta5d6ff" Tff7b72/>
Geocoder API
Tff7b72</label>
Tff7b72<input
Te6edf3v-model=Ta5d6ff"Ta5d6ffnominatimApiUrlTa5d6ff"
Te6edf3type=Ta5d6ff"Ta5d6fftextTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffw-full bg-gray-50/50 dark:bg-zinc-950/50 border border-gray-200 dark:border-zinc-800 rounded-lg px-2 py-1.5 text-[10px] dark:text-zinc-100 font-mono focus:ring-1 focus:ring-blue-500 transition-all outline-noneTa5d6ff"
Te6edf3:placeholder=Ta5d6ff"Te6edf3$tTa5d6ff(Ta5d6ff'Ta5d6ffmap.nominatim_api_url_placeholderTa5d6ff'Ta5d6ff)Ta5d6ff"
@Te6edf3blur=Ta5d6ff"Ta5d6ffsaveNominatimApiUrlTa5d6ff"
Tff7b72/>
Tff7b72</div>
Tff7b72</div>

T8b949e<!-- Live Tracking -->
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffspace-y-2Ta5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex items-center justify-betweenTa5d6ff"Tff7b72>
Tff7b72<label Te6edf3class=Ta5d6ff"Ta5d6fftext-[10px] font-bold text-gray-400 uppercase tracking-widestTa5d6ff"
Tff7b72>Live TrackingTff7b72</label
>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffh-px flex-1 bg-gray-100 dark:bg-zinc-800 ml-3Ta5d6ff"Tff7b72>Tff7b72</div>
Tff7b72</div>
Tff7b72<div Te6edf3v-if=Ta5d6ff"Ta5d6fftrackedPeers.length === 0Ta5d6ff" Te6edf3class=Ta5d6ff"Ta5d6fftext-[10px] text-gray-500 italic px-2Ta5d6ff"Tff7b72>
No peers currently being tracked.
Tff7b72</div>
Tff7b72<div v-else Te6edf3class=Ta5d6ff"Ta5d6ffspace-y-1Ta5d6ff"Tff7b72>
Tff7b72<div
Te6edf3v-for=Ta5d6ff"Ta5d6ffpeer in trackedPeersTa5d6ff"
Te6edf3:key=Ta5d6ff"Ta5d6ffpeer.destination_hashTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffflex items-center justify-between p-2 bg-gray-50 dark:bg-zinc-800/50 rounded-lg groupTa5d6ff"
Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex flex-col min-w-0Ta5d6ff"Tff7b72>
Tff7b72<span Te6edf3class=Ta5d6ff"Ta5d6fftext-[10px] font-bold text-gray-900 dark:text-zinc-100 truncateTa5d6ff"Tff7b72>
{{
peers[peer.destination_hash]?.display_name ||
peer.destination_hash.substring(0, 8)
}}
Tff7b72</span>
Tff7b72<span Te6edf3class=Ta5d6ff"Ta5d6fftext-[8px] text-gray-500 font-monoTa5d6ff"Tff7b72>
{{ peer.destination_hash }}
Tff7b72</span>
Tff7b72</div>
Tff7b72<button
Te6edf3class=Ta5d6ff"Ta5d6ffp-1 text-red-400 hover:text-red-600 transition-colors opacity-0 group-hover:opacity-100Ta5d6ff"
Te6edf3title=Ta5d6ff"Ta5d6ffStop TrackingTa5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6fftoggleTracking(peer.destination_hash)Ta5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffclose-circleTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-3Ta5d6ff" Tff7b72/>
Tff7b72</button>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>

T8b949e<!-- MBTiles Section -->
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffspace-y-3 pt-1Ta5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex items-center justify-betweenTa5d6ff"Tff7b72>
Tff7b72<label Te6edf3class=Ta5d6ff"Ta5d6fftext-[10px] font-bold text-gray-400 uppercase tracking-widestTa5d6ff"
Tff7b72>Offline MapsTff7b72</label
>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffh-px flex-1 bg-gray-100 dark:bg-zinc-800 ml-3Ta5d6ff"Tff7b72>Tff7b72</div>
Tff7b72</div>

Tff7b72<div
Te6edf3class=Ta5d6ff"Ta5d6ffflex items-center justify-between py-1 px-2 bg-gray-50/50 dark:bg-zinc-800/30 rounded-lg border border-gray-100 dark:border-zinc-800Ta5d6ff"
Tff7b72>
Tff7b72<span
Te6edf3class=Ta5d6ff"Ta5d6fftext-[10px] font-bold text-gray-600 dark:text-zinc-400 uppercase tracking-tightTa5d6ff"
Tff7b72>Tile CachingTff7b72</span
>
Tff7b72<Toggle Te6edf3:model-value=Ta5d6ff"Ta5d6ffcachingEnabledTa5d6ff" @Te6edf3update:model-value=Ta5d6ff"Ta5d6fftoggleCachingTa5d6ff" Tff7b72/>
Tff7b72</div>

Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffspace-y-1Ta5d6ff"Tff7b72>
Tff7b72<label
Te6edf3class=Ta5d6ff"Ta5d6fftext-[9px] font-bold text-gray-500 dark:text-zinc-500 uppercase flex items-centerTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6fffolder-outlineTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-3 mr-1Ta5d6ff" Tff7b72/>
Storage Path
Tff7b72</label>
Tff7b72<input
Te6edf3v-model=Ta5d6ff"Ta5d6ffmbtilesDirTa5d6ff"
Te6edf3type=Ta5d6ff"Ta5d6fftextTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffw-full bg-gray-50/50 dark:bg-zinc-950/50 border border-gray-200 dark:border-zinc-800 rounded-lg px-2 py-1.5 text-[10px] dark:text-zinc-100 font-mono focus:ring-1 focus:ring-blue-500 transition-all outline-noneTa5d6ff"
Te6edf3placeholder=Ta5d6ff"Ta5d6ffDefault storageTa5d6ff"
@Te6edf3blur=Ta5d6ff"Ta5d6ffsaveMBTilesDirTa5d6ff"
Tff7b72/>
Tff7b72</div>

Tff7b72<div Te6edf3v-if=Ta5d6ff"Ta5d6ffmbtilesList.length > 0Ta5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffspace-y-1.5Ta5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex items-center space-x-2 pb-0.5Ta5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffdatabase-outlineTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-3 text-blue-500Ta5d6ff" Tff7b72/>
Tff7b72<span
Te6edf3class=Ta5d6ff"Ta5d6fftext-[10px] font-bold text-gray-700 dark:text-zinc-300 uppercase tracking-tightTa5d6ff"
Tff7b72>MBTiles LibraryTff7b72</span
>
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffspace-y-1 max-h-40 overflow-y-auto pr-1 scrollbar-thinTa5d6ff"Tff7b72>
Tff7b72<div
Te6edf3v-for=Ta5d6ff"Ta5d6fffile in mbtilesListTa5d6ff"
Te6edf3:key=Ta5d6ff"Ta5d6fffile.nameTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffflex items-center justify-between p-2 rounded-xlTa5d6ff"
Te6edf3:class=Ta5d6ff"
Ta5d6ff file.is_active
? Ta5d6ff'Ta5d6ffbg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800/50Ta5d6ff'
Ta5d6ff : Ta5d6ff'Ta5d6ffbg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 shadow-smTa5d6ff'
Ta5d6ff Ta5d6ff"
Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex flex-col min-w-0 flex-1 mr-2Ta5d6ff"Tff7b72>
Tff7b72<span
Te6edf3class=Ta5d6ff"Ta5d6fftext-[10px] font-bold text-gray-900 dark:text-zinc-100 truncate leading-none mb-1Ta5d6ff"
Te6edf3:title=Ta5d6ff"Ta5d6fffile.nameTa5d6ff"
Tff7b72>{{ file.name }}Tff7b72</span
>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex items-center space-x-2Ta5d6ff"Tff7b72>
Tff7b72<span Te6edf3class=Ta5d6ff"Ta5d6fftext-[8px] font-black text-gray-400 uppercase tabular-numsTa5d6ff"
Tff7b72>{{ (file.size / 1024 / 1024).toFixed(1) }} MBTff7b72</span
>
Tff7b72<span
Te6edf3v-if=Ta5d6ff"Ta5d6fffile.is_activeTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6fftext-[8px] font-black text-blue-500 uppercaseTa5d6ff"
Tff7b72>ActiveTff7b72</span
>
Tff7b72</div>
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex items-center space-x-1Ta5d6ff"Tff7b72>
Tff7b72<button
Te6edf3v-if=Ta5d6ff"Ta5d6ff!file.is_activeTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffp-1.5 text-blue-500 hover:bg-blue-500 hover:text-white rounded-lg transition-all active:scale-90Ta5d6ff"
Te6edf3title=Ta5d6ff"Ta5d6ffSet as activeTa5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffsetActiveMBTiles(file.name)Ta5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffcheckTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-3.5Ta5d6ff" Tff7b72/>
Tff7b72</button>
Tff7b72<button
Te6edf3class=Ta5d6ff"Ta5d6ffp-1.5 text-red-500 hover:bg-red-500 hover:text-white rounded-lg transition-all active:scale-90Ta5d6ff"
Te6edf3title=Ta5d6ff"Ta5d6ffDeleteTa5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffdeleteMBTiles(file.name)Ta5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffdelete-outlineTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-3.5Ta5d6ff" Tff7b72/>
Tff7b72</button>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>

T8b949e<!-- Footer Stats -->
Tff7b72<div
Te6edf3class=Ta5d6ff"Ta5d6ffp-2.5 bg-gray-50 dark:bg-zinc-800/50 border-t border-gray-200 dark:border-zinc-800 shrink-0Ta5d6ff"
Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffgrid grid-cols-3 gap-2Ta5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex flex-col items-centerTa5d6ff"Tff7b72>
Tff7b72<span Te6edf3class=Ta5d6ff"Ta5d6fftext-[8px] font-black text-gray-400 uppercase tracking-tighter mb-0.5Ta5d6ff"
Tff7b72>ZoomTff7b72</span
>
Tff7b72<span
Te6edf3class=Ta5d6ff"Ta5d6fftext-[10px] font-mono font-bold text-gray-700 dark:text-zinc-300 leading-none tabular-numsTa5d6ff"
Tff7b72>{{ currentZoom.toFixed(1) }}Tff7b72</span
>
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex flex-col items-center border-x border-gray-200 dark:border-zinc-700Ta5d6ff"Tff7b72>
Tff7b72<span Te6edf3class=Ta5d6ff"Ta5d6fftext-[8px] font-black text-gray-400 uppercase tracking-tighter mb-0.5Ta5d6ff"
Tff7b72>LatTff7b72</span
>
Tff7b72<span
Te6edf3class=Ta5d6ff"Ta5d6fftext-[10px] font-mono font-bold text-gray-700 dark:text-zinc-300 leading-none tabular-numsTa5d6ff"
Tff7b72>{{ displayCoords[1].toFixed(4) }}Tff7b72</span
>
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex flex-col items-centerTa5d6ff"Tff7b72>
Tff7b72<span Te6edf3class=Ta5d6ff"Ta5d6fftext-[8px] font-black text-gray-400 uppercase tracking-tighter mb-0.5Ta5d6ff"
Tff7b72>LonTff7b72</span
>
Tff7b72<span
Te6edf3class=Ta5d6ff"Ta5d6fftext-[10px] font-mono font-bold text-gray-700 dark:text-zinc-300 leading-none tabular-numsTa5d6ff"
Tff7b72>{{ displayCoords[0].toFixed(4) }}Tff7b72</span
>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>

T8b949e<!-- offline warning overlay -->
Tff7b72<div
Te6edf3v-if=Ta5d6ff"Ta5d6ffshowOfflineHintTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffabsolute top-14 left-1/2 -translate-x-1/2 z-30 w-full max-w-sm px-4 animate-in fade-in slide-in-from-top-4 duration-500Ta5d6ff"
Tff7b72>
Tff7b72<div
Te6edf3class=Ta5d6ff"Ta5d6ffbg-amber-500 text-white rounded-xl shadow-2xl p-4 flex items-start space-x-3 border-2 border-amber-600/50Ta5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffwifi-offTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-6 shrink-0 mt-0.5Ta5d6ff" Tff7b72/>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex-1 space-y-2Ta5d6ff"Tff7b72>
Tff7b72<p Te6edf3class=Ta5d6ff"Ta5d6fftext-xs font-bold leading-tightTa5d6ff"Tff7b72>
Failed to fetch map tiles. You appear to be offline or off-grid.
Tff7b72</p>
Tff7b72<p Te6edf3class=Ta5d6ff"Ta5d6fftext-[10px] opacity-90 font-medium leading-relaxedTa5d6ff"Tff7b72>
Please use an
Tff7b72<strong
Te6edf3class=Ta5d6ff"Ta5d6fffont-bold text-white underline decoration-white/30 decoration-2 underline-offset-2Ta5d6ff"
Tff7b72>Offline MapTff7b72</strong
>
with MBTiles, or configure a local tile/geocoder server in the map settings.
Tff7b72</p>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex space-x-2 pt-1Ta5d6ff"Tff7b72>
Tff7b72<button
Te6edf3class=Ta5d6ff"Ta5d6ffpx-3 py-1 bg-white/20 hover:bg-white/30 rounded-lg text-[10px] font-bold uppercase transition-colors border border-white/20Ta5d6ff"
@Te6edf3click=Ta5d6ff"
Ta5d6ff isSettingsOpen = true;
showOfflineHint = false;
Ta5d6ff"
Tff7b72>
Open Settings
Tff7b72</button>
Tff7b72<button
Te6edf3class=Ta5d6ff"Ta5d6ffpx-3 py-1 bg-black/10 hover:bg-black/20 rounded-lg text-[10px] font-bold uppercase transition-colorsTa5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffshowOfflineHint = falseTa5d6ff"
Tff7b72>
Dismiss
Tff7b72</button>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>

T8b949e<!-- onboarding tooltip -->
Tff7b72<div
Te6edf3v-if=Ta5d6ff"Ta5d6ffshowOnboardingTooltipTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6fffixed inset-0 z-[100] pointer-events-noneTa5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffdismissOnboardingTooltipTa5d6ff"
Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffabsolute inset-0 bg-black/50 pointer-events-autoTa5d6ff"Tff7b72>Tff7b72</div>
Tff7b72<div
Te6edf3ref=Ta5d6ff"Ta5d6fftooltipElementTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffabsolute bg-white dark:bg-zinc-900 rounded-xl shadow-2xl border border-gray-200 dark:border-zinc-800 p-4 pointer-events-auto max-w-xs sm:max-w-smTa5d6ff"
Te6edf3:style=Ta5d6ff"Ta5d6fftooltipStyleTa5d6ff"
Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex items-start justify-between mb-2Ta5d6ff"Tff7b72>
Tff7b72<h3 Te6edf3class=Ta5d6ff"Ta5d6fffont-semibold text-gray-900 dark:text-zinc-100 text-smTa5d6ff"Tff7b72>
{{ Te6edf3$t("map.onboarding_title") }}
Tff7b72</h3>
Tff7b72<button
Te6edf3class=Ta5d6ff"Ta5d6fftext-gray-400 hover:text-gray-600 dark:hover:text-zinc-300Ta5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffdismissOnboardingTooltipTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffcloseTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-4Ta5d6ff" Tff7b72/>
Tff7b72</button>
Tff7b72</div>
Tff7b72<p Te6edf3class=Ta5d6ff"Ta5d6fftext-sm text-gray-600 dark:text-zinc-400 mb-3Ta5d6ff"Tff7b72>
{{ Te6edf3$t("map.onboarding_text") }}
Tff7b72</p>
Tff7b72<button
Te6edf3class=Ta5d6ff"Ta5d6ffw-full px-3 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors text-sm font-mediumTa5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffdismissOnboardingTooltipTa5d6ff"
Tff7b72>
{{ Te6edf3$t("map.onboarding_got_it") }}
Tff7b72</button>
Tff7b72</div>
Tff7b72<svg
Te6edf3v-if=Ta5d6ff"Ta5d6ffarrowPath && !isMobileScreenTa5d6ff"
Te6edf3ref=Ta5d6ff"Ta5d6ffarrowElementTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffabsolute pointer-events-noneTa5d6ff"
Te6edf3:style=Ta5d6ff"Ta5d6ffarrowStyleTa5d6ff"
Te6edf3:width=Ta5d6ff"Ta5d6ffarrowSvgWidthTa5d6ff"
Te6edf3:height=Ta5d6ff"Ta5d6ffarrowSvgHeightTa5d6ff"
Te6edf3:viewBox=Ta5d6ff"Ta5d6ff`0 0 Tff7b72${Te6edf3arrowSvgWidthTff7b72}Ta5d6ff Tff7b72${Te6edf3arrowSvgHeightTff7b72}Ta5d6ff`Ta5d6ff"
Tff7b72>
Tff7b72<path Te6edf3:d=Ta5d6ff"Ta5d6ffarrowPathTa5d6ff" Te6edf3stroke=Ta5d6ff"Ta5d6ff#3b82f6Ta5d6ff" Te6edf3stroke-width=Ta5d6ff"Ta5d6ff3Ta5d6ff" Te6edf3fill=Ta5d6ff"Ta5d6ffnoneTa5d6ff" Te6edf3marker-end=Ta5d6ff"Ta5d6ffurl(#arrowhead)Ta5d6ff" Tff7b72/>
Tff7b72<defsTff7b72>
Tff7b72<marker Te6edf3id=Ta5d6ff"Ta5d6ffarrowheadTa5d6ff" Te6edf3markerWidth=Ta5d6ff"Ta5d6ff10Ta5d6ff" Te6edf3markerHeight=Ta5d6ff"Ta5d6ff10Ta5d6ff" Te6edf3refX=Ta5d6ff"Ta5d6ff9Ta5d6ff" Te6edf3refY=Ta5d6ff"Ta5d6ff3Ta5d6ff" Te6edf3orient=Ta5d6ff"Ta5d6ffautoTa5d6ff"Tff7b72>
Tff7b72<polygon Te6edf3points=Ta5d6ff"Ta5d6ff0 0, 10 3, 0 6Ta5d6ff" Te6edf3fill=Ta5d6ff"Ta5d6ff#3b82f6Ta5d6ff" Tff7b72/>
Tff7b72</marker>
Tff7b72</defs>
Tff7b72</svg>
Tff7b72</div>
Tff7b72</div>

T8b949e<!-- save drawing modal -->
Tff7b72<div
Te6edf3v-if=Ta5d6ff"Ta5d6ffshowSaveDrawingModalTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6fffixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/40 backdrop-blur-smTa5d6ff"
Tff7b72>
Tff7b72<div
Te6edf3class=Ta5d6ff"Ta5d6ffbg-white dark:bg-zinc-900 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200Ta5d6ff"
Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffp-6Ta5d6ff"Tff7b72>
Tff7b72<h2 Te6edf3class=Ta5d6ff"Ta5d6fftext-xl font-bold text-gray-900 dark:text-white flex items-center gap-2Ta5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffcontent-save-outlineTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-6 text-blue-500Ta5d6ff" Tff7b72/>
{{ Te6edf3$t("map.save_drawing_title") }}
Tff7b72</h2>
Tff7b72<p Te6edf3class=Ta5d6ff"Ta5d6fftext-sm text-gray-500 dark:text-gray-400 mt-1Ta5d6ff"Tff7b72>{{ Te6edf3$t("map.save_drawing_desc") }}Tff7b72</p>

Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffmt-6Ta5d6ff"Tff7b72>
Tff7b72<label
Te6edf3class=Ta5d6ff"Ta5d6ffblock text-xs font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-widest mb-2Ta5d6ff"
Tff7b72>
{{ Te6edf3$t("map.drawing_name") }}
Tff7b72</label>
Tff7b72<input
Te6edf3ref=Ta5d6ff"Ta5d6ffnewDrawingNameInputTa5d6ff"
Te6edf3v-model=Ta5d6ff"Ta5d6ffnewDrawingNameTa5d6ff"
Te6edf3type=Ta5d6ff"Ta5d6fftextTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffw-full px-4 py-3 bg-gray-50 dark:bg-zinc-800 border-none rounded-xl text-sm focus:ring-2 focus:ring-blue-500 transition-all dark:text-whiteTa5d6ff"
Te6edf3:placeholder=Ta5d6ff"Te6edf3$tTa5d6ff(Ta5d6ff'Ta5d6ffmap.drawing_name_placeholderTa5d6ff'Ta5d6ff)Ta5d6ff"
@keyup.Te6edf3enter=Ta5d6ff"Ta5d6ffsaveDrawingTa5d6ff"
Tff7b72/>
Tff7b72</div>

Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffmt-8 flex gap-3Ta5d6ff"Tff7b72>
Tff7b72<button
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffflex-1 px-4 py-2.5 rounded-xl border border-gray-200 dark:border-zinc-700 text-sm font-semibold text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-zinc-800 transitionTa5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffshowSaveDrawingModal = falseTa5d6ff"
Tff7b72>
{{ Te6edf3$t("common.close") }}
Tff7b72</button>
Tff7b72<button
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffflex-1 px-4 py-2.5 rounded-xl bg-blue-600 text-white text-sm font-semibold shadow-lg shadow-blue-500/25 hover:bg-blue-500 transition active:scale-95 disabled:opacity-50Ta5d6ff"
Te6edf3:disabled=Ta5d6ff"Ta5d6ff!newDrawingName.trim()Ta5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffsaveDrawingTa5d6ff"
Tff7b72>
{{ Te6edf3$t("common.save") }}
Tff7b72</button>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>

T8b949e<!-- load drawing modal -->
Tff7b72<div
Te6edf3v-if=Ta5d6ff"Ta5d6ffshowLoadDrawingModalTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6fffixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/40 backdrop-blur-smTa5d6ff"
Tff7b72>
Tff7b72<div
Te6edf3class=Ta5d6ff"Ta5d6ffbg-white dark:bg-zinc-900 w-full max-w-lg rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200Ta5d6ff"
Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffp-6Ta5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex items-center justify-between mb-6Ta5d6ff"Tff7b72>
Tff7b72<h2 Te6edf3class=Ta5d6ff"Ta5d6fftext-xl font-bold text-gray-900 dark:text-white flex items-center gap-2Ta5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6fffolder-open-outlineTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-6 text-blue-500Ta5d6ff" Tff7b72/>
{{ Te6edf3$t("map.load_drawing_title") }}
Tff7b72</h2>
Tff7b72<button Te6edf3class=Ta5d6ff"Ta5d6fftext-gray-400 hover:text-gray-600Ta5d6ff" @Te6edf3click=Ta5d6ff"Ta5d6ffshowLoadDrawingModal = falseTa5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffcloseTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-6Ta5d6ff" Tff7b72/>
Tff7b72</button>
Tff7b72</div>

Tff7b72<div Te6edf3v-if=Ta5d6ff"Ta5d6ffisLoadingDrawingsTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffpy-12 flex flex-col items-center justify-centerTa5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffloadingTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-10 animate-spin text-blue-500 mb-4Ta5d6ff" Tff7b72/>
Tff7b72<span Te6edf3class=Ta5d6ff"Ta5d6fftext-sm font-medium text-gray-500Ta5d6ff"Tff7b72>{{ Te6edf3$t("map.loading_drawings") }}Tff7b72</span>
Tff7b72</div>

Tff7b72<div
Te6edf3v-else-if=Ta5d6ff"Ta5d6ffsavedDrawings.length === 0Ta5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffpy-12 flex flex-col items-center justify-center text-centerTa5d6ff"
Tff7b72>
Tff7b72<div
Te6edf3class=Ta5d6ff"Ta5d6ffsize-16 bg-gray-100 dark:bg-zinc-800 rounded-full flex items-center justify-center mb-4Ta5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6fffolder-outlineTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-8 text-gray-400Ta5d6ff" Tff7b72/>
Tff7b72</div>
Tff7b72<h3 Te6edf3class=Ta5d6ff"Ta5d6fftext-lg font-bold text-gray-900 dark:text-whiteTa5d6ff"Tff7b72>{{ Te6edf3$t("map.no_drawings") }}Tff7b72</h3>
Tff7b72<p Te6edf3class=Ta5d6ff"Ta5d6fftext-sm text-gray-500 dark:text-gray-400 mt-1Ta5d6ff"Tff7b72>{{ Te6edf3$t("map.no_drawings_desc") }}Tff7b72</p>
Tff7b72</div>

Tff7b72<div v-else Te6edf3class=Ta5d6ff"Ta5d6ffmax-h-[400px] overflow-y-auto space-y-2 pr-2Ta5d6ff"Tff7b72>
Tff7b72<div
Te6edf3v-for=Ta5d6ff"Ta5d6ffdrawing in savedDrawingsTa5d6ff"
Te6edf3:key=Ta5d6ff"Ta5d6ffdrawing.idTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffgroup p-4 bg-gray-50 dark:bg-zinc-800/50 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-2xl border border-transparent hover:border-blue-200 dark:hover:border-blue-800 transition-all cursor-pointer flex items-center justify-betweenTa5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffloadDrawing(drawing)Ta5d6ff"
Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffflex-1 min-w-0 mr-4Ta5d6ff"Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6fffont-bold text-gray-900 dark:text-white truncateTa5d6ff"Tff7b72>{{ drawing.name }}Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6fftext-xs text-gray-500 dark:text-zinc-500 mt-0.5Ta5d6ff"Tff7b72>
{{ Te6edf3$t("map.saved_on") }} {{ new Date(drawing.updated_at).toLocaleString() }}
Tff7b72</div>
Tff7b72</div>
Tff7b72<button
Te6edf3class=Ta5d6ff"Ta5d6ffp-2 text-gray-400 hover:text-red-500 transition-colorsTa5d6ff"
Te6edf3:title=Ta5d6ff"Te6edf3$tTa5d6ff(Ta5d6ff'Ta5d6ffcommon.deleteTa5d6ff'Ta5d6ff)Ta5d6ff"
@click.Te6edf3stop=Ta5d6ff"Ta5d6ffdeleteDrawing(drawing)Ta5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6fftrash-can-outlineTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-5Ta5d6ff" Tff7b72/>
Tff7b72</button>
Tff7b72</div>
Tff7b72</div>

Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffmt-8 flex justify-endTa5d6ff"Tff7b72>
Tff7b72<button
Te6edf3type=Ta5d6ff"Ta5d6ffbuttonTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffpx-6 py-2.5 rounded-xl border border-gray-200 dark:border-zinc-700 text-sm font-semibold text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-zinc-800 transitionTa5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffshowLoadDrawingModal = falseTa5d6ff"
Tff7b72>
{{ Te6edf3$t("common.close") }}
Tff7b72</button>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>

T8b949e<!-- mobile note modal -->
Tff7b72<transition Te6edf3name=Ta5d6ff"Ta5d6fffadeTa5d6ff"Tff7b72>
Tff7b72<div
Te6edf3v-if=Ta5d6ff"Ta5d6ffshowNoteModalTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6fffixed inset-0 z-[100] flex items-end sm:items-center justify-center p-4 bg-black/50 backdrop-blur-smTa5d6ff"
@click.Te6edf3self=Ta5d6ff"Ta5d6ffcloseNoteEditorTa5d6ff"
Tff7b72>
Tff7b72<div
Te6edf3class=Ta5d6ff"Ta5d6ffbg-white dark:bg-zinc-900 w-full max-w-lg rounded-t-2xl sm:rounded-2xl shadow-2xl overflow-hidden animate-slide-up sm:animate-fade-inTa5d6ff"
Tff7b72>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffp-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-betweenTa5d6ff"Tff7b72>
Tff7b72<h3 Te6edf3class=Ta5d6ff"Ta5d6fftext-lg font-bold text-gray-900 dark:text-white flex items-center gap-2Ta5d6ff"Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffnote-editTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-5 text-amber-500Ta5d6ff" Tff7b72/>
Edit Note
Tff7b72</h3>
Tff7b72<button
Te6edf3class=Ta5d6ff"Ta5d6ffp-2 text-gray-400 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-full transition-colorsTa5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffcloseNoteEditorTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6ffcloseTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-5Ta5d6ff" Tff7b72/>
Tff7b72</button>
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffp-4Ta5d6ff"Tff7b72>
Tff7b72<textarea
Te6edf3v-model=Ta5d6ff"Ta5d6ffnoteTextTa5d6ff"
Te6edf3class=Ta5d6ff"Ta5d6ffw-full h-40 p-4 text-base bg-gray-50 dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded-xl focus:ring-2 focus:ring-amber-500 focus:border-transparent outline-none resize-none text-gray-900 dark:text-zinc-100Ta5d6ff"
Te6edf3placeholder=Ta5d6ff"Ta5d6ffType your note here...Ta5d6ff"
autofocus
Tff7b72>Tff7b72</textarea>
Tff7b72</div>
Tff7b72<div Te6edf3class=Ta5d6ff"Ta5d6ffp-4 bg-gray-50 dark:bg-zinc-800/50 flex justify-between gap-3Ta5d6ff"Tff7b72>
Tff7b72<button
Te6edf3class=Ta5d6ff"Ta5d6ffflex-1 px-4 py-3 text-sm font-bold text-red-500 hover:bg-red-100 dark:hover:bg-red-900/20 rounded-xl transition-colors flex items-center justify-center gap-2Ta5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffdeleteNoteTa5d6ff"
Tff7b72>
Tff7b72<MaterialDesignIcon Te6edf3icon-name=Ta5d6ff"Ta5d6fftrash-can-outlineTa5d6ff" Te6edf3class=Ta5d6ff"Ta5d6ffsize-5Ta5d6ff" Tff7b72/>
Delete
Tff7b72</button>
Tff7b72<button
Te6edf3class=Ta5d6ff"Ta5d6ffflex-[2] px-4 py-3 text-sm font-bold bg-amber-500 text-white hover:bg-amber-600 rounded-xl shadow-lg shadow-amber-500/30 transition-colorsTa5d6ff"
@Te6edf3click=Ta5d6ff"Ta5d6ffsaveNoteTa5d6ff"
Tff7b72>
Save Note
Tff7b72</button>
Tff7b72</div>
Tff7b72</div>
Tff7b72</div>
Tff7b72</transition>
Tff7b72</div>
Tff7b72</template>

Tff7b72<scriptTff7b72>
import "ol/ol.css";
import Map from "ol/Map";
import View from "ol/View";
import TileLayer from "ol/layer/Tile";
import VectorLayer from "ol/layer/Vector";
import XYZ from "ol/source/XYZ";
import VectorSource from "ol/source/Vector";
import Feature from "ol/Feature";
import Point from "ol/geom/Point";
import * as mdi from "@mdi/js";
import { Style, Text, Fill, Stroke, Circle as CircleStyle, Icon } from "ol/style";
import { fromLonLat, toLonLat } from "ol/proj";
import { defaults as defaultControls } from "ol/control";
import DragBox from "ol/interaction/DragBox";
import Draw from "ol/interaction/Draw";
import Modify from "ol/interaction/Modify";
import Snap from "ol/interaction/Snap";
import Select from "ol/interaction/Select";
import Translate from "ol/interaction/Translate";
import { getArea, getLength } from "ol/sphere";
import { LineString, Polygon, Circle } from "ol/geom";
import { fromCircle } from "ol/geom/Polygon";
import { unByKey } from "ol/Observable";
import Overlay from "ol/Overlay";
import GeoJSON from "ol/format/GeoJSON";
import {
extend as extendExtent,
extendCoordinate,
createEmpty as createEmptyExtent,
isEmpty as isExtentEmpty,
} from "ol/extent";
import {
extentDiagonal as computeExtentDiagonal,
buildClusterItems as buildClusterItemsHelper,
} from "./internal/clusterUtils.js";
import { getDiscoveredIconName as getDiscoveredIconNameHelper } from "./internal/discoveredIcons.js";
import {
dedupeDiscoveredMapNodes as dedupeDiscoveredMapNodesHelper,
dedupeTelemetryMarkersForMap as dedupeTelemetryMarkersForMapHelper,
} from "./internal/mapDedupe.js";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ContextMenuItem from "../contextmenu/ContextMenuItem.vue";
import ContextMenuPanel from "../contextmenu/ContextMenuPanel.vue";
import ToastUtils from "../../js/ToastUtils";
import TileCache from "../../js/TileCache";
import Toggle from "../forms/Toggle.vue";
import WebSocketConnection from "../../js/WebSocketConnection";
import MapClusterPanel from "./internal/MapClusterPanel.vue";
import MapMarkerPanel from "./internal/MapMarkerPanel.vue";
import MapDrawingToolbar from "./internal/MapDrawingToolbar.vue";
import MapSearchBar from "./internal/MapSearchBar.vue";
import MapExportInstructions from "./internal/MapExportInstructions.vue";
import MapExportConfigPanel from "./internal/MapExportConfigPanel.vue";
import MapExportProgressPanel from "./internal/MapExportProgressPanel.vue";
import MapNoMapWarning from "./internal/MapNoMapWarning.vue";
import MapLoadingOverlay from "./internal/MapLoadingOverlay.vue";

export default {
name: "MapPage",
components: {
ContextMenuItem,
ContextMenuPanel,
MaterialDesignIcon,
Toggle,
MapClusterPanel,
MapMarkerPanel,
MapDrawingToolbar,
MapSearchBar,
MapExportInstructions,
MapExportConfigPanel,
MapExportProgressPanel,
MapNoMapWarning,
MapLoadingOverlay,
},
data() {
return {
map: null,
offlineEnabled: false,
hasOfflineMap: false,
metadata: null,
isUploading: false,
isSettingsOpen: false,
currentCenter: [0, 0],
currentZoom: 2,
cursorCoords: null,
config: null,
peers: {},

// telemetry
telemetryList: [],
markerSource: null,
markerLayer: null,
historySource: null,
historyLayer: null,
selectedMarker: null,
selectedCluster: null,
isMiniChatOpen: false,
queryMarker: null,
discoveredMarkers: [],
discoveredVisible: false,

// caching
cachingEnabled: true,

// tile server
tileServerUrl: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",

// search
searchQuery: "",
searchResults: [],
isSearching: false,
isSearchFocused: false,
searchError: null,
nominatimApiUrl: "https://nominatim.openstreetmap.org",
searchTimeout: null,

// export mode
isExportMode: false,
dragBox: null,
selectedBbox: null,
exportMinZoom: 0,
exportMaxZoom: 10,
isExporting: false,
exportId: null,
exportStatus: null,
exportInterval: null,

// onboarding
showOnboardingTooltip: false,
tooltipStyle: {},
arrowStyle: {},
arrowPath: null,
arrowSvgWidth: 200,
arrowSvgHeight: 200,
isMobileScreen: false,
isMobileSearchOpen: false,

// MBTiles management
mbtilesList: [],
mbtilesDir: "",
isMapLoaded: false,
tileErrorCount: 0,
showOfflineHint: false,

// drawing tools
draw: null,
modify: null,
snap: null,
drawSource: null,
drawLayer: null,
drawType: null, // 'Point', 'LineString', 'Polygon', 'Circle' or null
isDrawing: false,
drawingTools: [
{ type: "Select", icon: "cursor-default" },
{ type: "Point", icon: "map-marker-plus" },
{ type: "LineString", icon: "vector-line" },
{ type: "Polygon", icon: "vector-polygon" },
{ type: "Circle", icon: "circle-outline" },
{ type: "Export", icon: "crop-free" },
],

// measurement
isMeasuring: false,
sketch: null,
helpTooltipElement: null,
helpTooltip: null,
measureTooltipElement: null,
measureTooltip: null,
measurementOverlays: [],

// drawing storage
savedDrawings: [],

// note editing
editingFeature: null,
noteText: "",
hoveredFeature: null,
hoveredMarker: null,
noteOverlay: null,
showNoteModal: false,
showSaveDrawingModal: false,
newDrawingName: "",
isLoadingDrawings: false,
showLoadDrawingModal: false,
styleCache: {},
selectedFeature: null,
select: null,
translate: null,
// context menu
showContextMenu: false,
contextMenuPos: { x: 0, y: 0 },
contextMenuFeature: null,
contextMenuCoord: null,
};
},
computed: {
popoutRouteType() {
if (this.$route?.meta?.popoutType) {
return this.$route.meta.popoutType;
}
return this.$route?.query?.popout ?? this.getHashPopoutValue();
},
isPopoutMode() {
return this.popoutRouteType === "map";
},
trackedPeers() {
return this.telemetryList.filter((t) => t.is_tracking);
},
estimatedTiles() {
if (!this.selectedBbox) return 0;
const [minLon, minLat, maxLon, maxLat] = this.selectedBbox;
let total = 0;
for (let z = this.exportMinZoom; z <= this.exportMaxZoom; z++) {
const x1 = this.lonToTile(minLon, z);
const x2 = this.lonToTile(maxLon, z);
const y1 = this.latToTile(maxLat, z);
const y2 = this.latToTile(minLat, z);
total += (Math.abs(x2 - x1) + 1) * (Math.abs(y2 - y1) + 1);
}
return total;
},
displayCoords() {
return this.cursorCoords || this.currentCenter;
},
},
watch: {
selectedMarker(newVal, oldVal) {
// Close mini-chat if the selected peer changed
if (!newVal || !oldVal || newVal.telemetry?.destination_hash !== oldVal.telemetry?.destination_hash) {
this.isMiniChatOpen = false;
}
},
showSaveDrawingModal(val) {
if (val) {
this.$nextTick(() => {
this.$refs.newDrawingNameInput?.focus();
});
}
},
},
async mounted() {
await this.getConfig();

// Load persisted map state
try {
const savedState = await TileCache.getMapState("last_view");
if (savedState) {
this.currentCenter = savedState.center || [0, 0];
this.currentZoom = savedState.zoom || 2;
if (savedState.offlineEnabled !== undefined) this.offlineEnabled = savedState.offlineEnabled;
if (savedState.tileServerUrl) this.tileServerUrl = savedState.tileServerUrl;
if (savedState.telemetry) this.telemetryList = savedState.telemetry;

// Temporarily store drawings to restore after map/source init
this._persistedDrawings = savedState.drawings;
}
} catch (e) {
console.warn("Failed to load map state from cache", e);
}

this.initMap();

if (this.telemetryList.length > 0) {
this.updateMarkers();
}

// Restore drawings if any
if (this._persistedDrawings && this.drawSource) {
try {
const format = new GeoJSON();
const features = format.readFeatures(this._persistedDrawings, {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
});
console.log("Restoring persisted drawings, count:", features.length);
this.drawSource.addFeatures(features);
this.rebuildMeasurementOverlays();
} catch (e) {
console.error("Failed to restore persisted drawings", e);
}
delete this._persistedDrawings;
}
await this.checkOfflineMap();
await this.loadMBTilesList();

await this.fetchPeers();
await this.fetchTelemetryMarkers();

// Handle view modes
if (this.$route.query.view === "discovered") {
await this.mapDiscoveredNodes();
}

// Listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);

// Check for query params to center map (overrides saved state)
if (this.$route.query.lat && this.$route.query.lon) {
const lat = parseFloat(this.$route.query.lat);
const lon = parseFloat(this.$route.query.lon);
const zoom = parseInt(this.$route.query.zoom || 15);
const label = this.$route.query.label || "Target";

if (!isNaN(lat) && !isNaN(lon)) {
this.map.getView().setCenter(fromLonLat([lon, lat]));
this.map.getView().setZoom(zoom);

// add a temporary marker for the query target
const feature = new Feature({
geometry: new Point(fromLonLat([lon, lat])),
originalCoord: fromLonLat([lon, lat]),
});
feature.setStyle(
this.createMarkerStyle({
iconColor: "#2563eb",
bgColor: "#bfdbfe",
label,
isStale: false,
iconPath: null,
})
);
this.queryMarker = feature;
if (this.markerSource) {
this.markerSource.addFeature(feature);
}
}
}

// Listen for moveend to update coordinates in UI and save state
if (this.map) {
this.map.on("moveend", () => {
const view = this.map.getView();
this.currentCenter =
view && typeof view.getCenter === "function" ? toLonLat(view.getCenter()) : this.currentCenter;
this.currentZoom = view && typeof view.getZoom === "function" ? view.getZoom() : this.currentZoom;
this.saveMapState();
this.updateMarkers();
});
}

// Check if onboarding tooltip should be shown
this.checkOnboardingTooltip();

// Add click outside handler for search
document.addEventListener("click", this.handleClickOutside);

// Check screen size for mobile
this.checkScreenSize();
window.addEventListener("resize", this.checkScreenSize);

// Update info every few seconds
this.reloadInterval = setInterval(() => {
this.fetchTelemetryMarkers();
}, 30000);
},
beforeUnmount() {
if (this.map && this.map.getViewport()) {
this.map.getViewport().removeEventListener("contextmenu", this.onContextMenu);
}
document.removeEventListener("click", this.handleGlobalClick);
if (this._saveStateTimer) {
clearTimeout(this._saveStateTimer);
this._saveStateTimer = null;
}
if (this._pendingSaveResolvers && this._pendingSaveResolvers.length > 0) {
const pending = this._pendingSaveResolvers.slice();
this._pendingSaveResolvers = [];
this.saveMapStateImmediate().then(() => pending.forEach((p) => p.resolve()));
}
if (this.reloadInterval) clearInterval(this.reloadInterval);
if (this.exportInterval) clearInterval(this.exportInterval);
if (this.searchTimeout) clearTimeout(this.searchTimeout);
document.removeEventListener("click", this.handleClickOutside);
window.removeEventListener("resize", this.checkScreenSize);
WebSocketConnection.off("message", this.onWebsocketMessage);
},
methods: {
saveMapState() {
if (!this._pendingSaveResolvers) {
this._pendingSaveResolvers = [];
}
return new Promise((resolve, reject) => {
this._pendingSaveResolvers.push({ resolve, reject });
if (this._saveStateTimer) clearTimeout(this._saveStateTimer);
this._saveStateTimer = setTimeout(async () => {
const pending = this._pendingSaveResolvers.slice();
this._pendingSaveResolvers = [];
this._saveStateTimer = null;
try {
await this.saveMapStateImmediate();
pending.forEach((p) => p.resolve());
} catch (e) {
pending.forEach((p) => p.reject(e));
}
}, 150);
});
},
async saveMapStateImmediate() {
try {
let drawings = null;
if (this.drawSource) {
const format = new GeoJSON();
const features = this.serializeFeatures(this.drawSource.getFeatures());
drawings = format.writeFeatures(features, {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
});
}
const state = JSON.parse(
JSON.stringify({
center: this.currentCenter,
zoom: this.currentZoom,
offlineEnabled: this.offlineEnabled,
tileServerUrl: this.tileServerUrl,
drawings: drawings,
telemetry: this.telemetryList,
})
);
await TileCache.setMapState("last_view", state);
console.log("Map state persisted to cache, drawings size:", drawings ? drawings.length : 0);
} catch (e) {
console.error("Failed to save map state", e);
}
},
async getConfig() {
try {
const response = await window.api.get("/api/v1/config");
this.config = response.data.config;
this.offlineEnabled = this.config.map_offline_enabled;
this.cachingEnabled =
this.config.map_tile_cache_enabled !== undefined ? this.config.map_tile_cache_enabled : true;
this.mbtilesDir = this.config.map_mbtiles_dir || "";
if (this.config.map_tile_server_url) {
this.tileServerUrl = this.config.map_tile_server_url;
}
if (this.config.map_nominatim_api_url) {
this.nominatimApiUrl = this.config.map_nominatim_api_url;
}
} catch (e) {
console.error("Failed to load config", e);
}
},
async loadMBTilesList() {
try {
const response = await window.api.get("/api/v1/map/mbtiles");
this.mbtilesList = response.data;
} catch (e) {
console.error("Failed to load MBTiles list", e);
}
},
async setActiveMBTiles(filename) {
try {
await window.api.post("/api/v1/map/mbtiles/active", { filename });
await this.checkOfflineMap();
await this.loadMBTilesList();
ToastUtils.success(this.$t("map.source_updated"));
} catch {
ToastUtils.error(this.$t("map.failed_set_active"));
}
},
async deleteMBTiles(filename) {
if (!confirm(`Are you sure you want to delete ${filename}?`)) return;
try {
await window.api.delete(`/api/v1/map/mbtiles/${filename}`);
await this.loadMBTilesList();
if (this.metadata && this.metadata.path && this.metadata.path.endsWith(filename)) {
await this.checkOfflineMap();
}
ToastUtils.success(this.$t("map.file_deleted"));
} catch {
ToastUtils.error(this.$t("map.failed_delete_file"));
}
},
async saveMBTilesDir() {
try {
await window.api.patch("/api/v1/config", {
map_mbtiles_dir: this.mbtilesDir,
});
ToastUtils.success(this.$t("map.storage_saved"));
this.loadMBTilesList();
} catch {
ToastUtils.error(this.$t("map.failed_save_storage"));
}
},
initMap() {
// Patch canvas getContext to address performance warning
const originalGetContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (type, attributes) {
if (type === "2d") {
attributes = attributes || {};
attributes.willReadFrequently = true;
}
return originalGetContext.call(this, type, attributes);
};

const defaultLat = parseFloat(this.config?.map_default_lat || 0);
const defaultLon = parseFloat(this.config?.map_default_lon || 0);
const defaultZoom = parseInt(this.config?.map_default_zoom || 2);

// Use saved state if available, otherwise use defaults
const startCenter =
this.currentCenter[0] !== 0 || this.currentCenter[1] !== 0
? fromLonLat(this.currentCenter)
: fromLonLat([defaultLon, defaultLat]);
const startZoom = this.currentZoom !== 2 ? this.currentZoom : defaultZoom;

this.map = new Map({
target: this.$refs.mapContainer,
layers: [
new TileLayer({
source: this.getTileSource(),
}),
],
view: new View({
center: startCenter,
zoom: startZoom,
}),
controls: defaultControls({
attribution: false,
rotate: false,
}),
});

// setup drawing layer
this.drawSource = new VectorSource();
this.drawLayer = new VectorLayer({
source: this.drawSource,
style: (feature) => {
const type = feature.get("type");
const geometry = feature.getGeometry();
const geomType = geometry ? geometry.getType() : null;

if (type === "note" || geomType === "Point") {
const isNote = type === "note";
return this.createMarkerStyle({
iconColor: isNote ? "#f59e0b" : "#3b82f6",
bgColor: "#ffffff",
label: isNote && feature.get("note") ? "Note" : "",
isStale: false,
iconPath: isNote
? "M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"
: null,
});
}
return new Style({
fill: new Fill({
color: "rgba(59, 130, 246, 0.2)",
}),
stroke: new Stroke({
color: "#3b82f6",
width: 3,
}),
image: new CircleStyle({
radius: 7,
fill: new Fill({
color: "#3b82f6",
}),
}),
});
},
zIndex: 50,
});
this.map.addLayer(this.drawLayer);
this.attachDrawPersistence();

this.noteOverlay = new Overlay({
element: this.$refs.noteOverlayElement,
autoPan: {
animation: {
duration: 250,
},
},
});
this.map.addOverlay(this.noteOverlay);

this.modify = new Modify({ source: this.drawSource });
this.modify.on("modifystart", (e) => {
const feats = (e.features && e.features.getArray()) || this.select.getFeatures().getArray();
feats.forEach((f) => this.clearMeasurementOverlay(f));
});
this.modify.on("modifyend", (e) => {
const feats = (e.features && e.features.getArray()) || this.select.getFeatures().getArray();
feats.forEach((f) => this.finalizeMeasurementOverlay(f));
this.saveMapState();
});
this.map.addInteraction(this.modify);

this.select = new Select({
layers: [this.drawLayer],
hitTolerance: 15, // High tolerance for touch/offgrid
style: null, // Keep original feature style
});
this.select.on("select", (e) => {
this.selectedFeature = e.selected[0] || null;
});
this.map.addInteraction(this.select);

this.translate = new Translate({
features: this.select.getFeatures(),
layers: [this.drawLayer], // Only move drawing layer items, not telemetry
});
this.translate.on("translateend", (e) => {
const feats = (e.features && e.features.getArray()) || this.select.getFeatures().getArray();
feats.forEach((f) => this.finalizeMeasurementOverlay(f));
this.saveMapState();
});
this.map.addInteraction(this.translate);

// Default to Select tool
this.drawType = "Select";
this.select.setActive(true);
this.translate.setActive(true);
this.modify.setActive(true);

this.snap = new Snap({ source: this.drawSource });
this.map.addInteraction(this.snap);

// Right-click context menu
this.map.getViewport().addEventListener("contextmenu", this.onContextMenu);

// setup history layer (trail)
this.historySource = new VectorSource();
this.historyLayer = new VectorLayer({
source: this.historySource,
style: new Style({
stroke: new Stroke({
color: "rgba(234, 179, 8, 0.6)", // yellow-500 light
width: 3,
lineDash: [10, 10], // dashed trail
}),
}),
zIndex: 40,
});
this.map.addLayer(this.historyLayer);

// setup telemetry markers
this.markerSource = new VectorSource();
this.markerLayer = new VectorLayer({
source: this.markerSource,
style: (feature) => {
const isHovered = this.hoveredMarker === feature;

if (feature.get("cluster")) {
const style = this.createClusterStyle(feature.get("clusterCount") || 0, isHovered);
style.setZIndex(isHovered ? 1000 : 200);
return style;
}

const scale = isHovered ? 0.85 : 0.6;
const zIndex = isHovered ? 1000 : 100;

const t = feature.get("telemetry");
const peer = feature.get("peer");
const disc = feature.get("discovered");

let displayName = "";
let isStale = false;
let iconColor = "#2563eb";
let bgColor = "#ffffff";
let iconPath = null;

if (t) {
displayName = peer?.display_name || t.destination_hash.substring(0, 8);
// Calculate staleness
const now = Date.now();
const updatedAt = t.updated_at
? new Date(t.updated_at).getTime()
: t.timestamp
? t.timestamp * 1000
: now;
isStale = now - updatedAt > 10 * 60 * 1000;

if (peer?.lxmf_user_icon) {
iconColor = peer.lxmf_user_icon.foreground_colour || iconColor;
bgColor = peer.lxmf_user_icon.background_colour || bgColor;
if (peer.lxmf_user_icon.icon_name) {
iconPath = this.getMdiPath(peer.lxmf_user_icon.icon_name);
}
}
} else if (disc) {
displayName = disc.name;
iconColor = "#10b981"; // emerald-500
bgColor = "#d1fae5"; // emerald-100
iconPath = this.getMdiPath(this.getDiscoveredIconName(disc));
} else if (feature === this.queryMarker) {
displayName = "Search Result";
iconColor = "#ef4444";
}

const style = this.createMarkerStyle({
iconColor,
bgColor,
label: displayName,
isStale,
iconPath,
scale,
isTracking: t ? t.is_tracking : false,
});
style.setZIndex(zIndex);
return style;
},
zIndex: 100,
});
this.map.addLayer(this.markerLayer);

this.map.on("pointermove", this.handleMapPointerMove);
this.map.on("click", (evt) => {
this.handleMapClick(evt);
this.closeContextMenu();
const feature = this.map.forEachFeatureAtPixel(evt.pixel, (f) => f);
if (feature && feature.get("cluster")) {
this.openCluster(feature);
} else if (feature && (feature.get("telemetry") || feature.get("discovered"))) {
this.onMarkerClick(feature);
this.selectedCluster = null;
} else {
this.selectedMarker = null;
this.selectedCluster = null;
}

// Deselect drawing if clicking empty space
if (!feature && this.select) {
this.select.getFeatures().clear();
this.selectedFeature = null;
}
});

this.currentCenter = [defaultLon, defaultLat];
this.currentZoom = defaultZoom;

// Setup dragBox for export
this.dragBox = new DragBox({
condition: () => this.isExportMode,
});

this.dragBox.on("boxend", () => {
const extent = this.dragBox.getGeometry().getExtent();
const min = toLonLat([extent[0], extent[1]]);
const max = toLonLat([extent[2], extent[3]]);
this.selectedBbox = [min[0], min[1], max[0], max[1]];
this.exportMinZoom = Math.floor(this.map.getView().getZoom());
this.exportMaxZoom = Math.min(this.exportMinZoom + 3, 18);
});

this.map.addInteraction(this.dragBox);
this.isMapLoaded = true;

// Close context menu when clicking elsewhere
document.addEventListener("click", this.handleGlobalClick);
},
isLocalUrl(url) {
if (!url) return false;
try {
const urlObj = new URL(url, window.location.origin);
return (
urlObj.hostname === "localhost" ||
urlObj.hostname === "127.0.0.1" ||
urlObj.hostname === "::1" ||
urlObj.hostname.startsWith("192.168.") ||
urlObj.hostname.startsWith("10.") ||
urlObj.hostname.startsWith("172.") ||
urlObj.hostname.endsWith(".local") ||
url.startsWith("/")
);
} catch {
return url.startsWith("/") || url.startsWith("./") || !url.startsWith("http");
}
},
isDefaultOnlineUrl(url) {
if (!url) return false;
const onlinePatterns = [
"openstreetmap.org",
"cartocdn.com",
"thunderforest.com",
"stamen.com",
"google.com",
"mapbox.com",
"arcgisonline.com",
"wmflabs.org",
"maptiler.com",
];
const lowerUrl = url.toLowerCase();
return onlinePatterns.some((pattern) => lowerUrl.includes(pattern));
},
async checkApiConnection(url) {
if (!url || this.isLocalUrl(url)) {
return true;
}
try {
let testUrl = url.endsWith("/") ? url.slice(0, -1) : url;
if (testUrl.includes("{z}") || testUrl.includes("{x}") || testUrl.includes("{y}")) {
testUrl = testUrl.replace("{z}", "0").replace("{x}", "0").replace("{y}", "0");
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 3000);
const response = await fetch(testUrl, {
method: "HEAD",
signal: controller.signal,
headers: {
"User-Agent": "ReticulumMeshChatX/1.0",
},
});
clearTimeout(timeoutId);
return response.ok || response.status === 405 || response.status === 404;
} catch {
return false;
}
},
getTileSource() {
const isOffline = this.offlineEnabled;
const defaultTileUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const customTileUrl = this.tileServerUrl || defaultTileUrl;
const isCustomLocal = this.isLocalUrl(customTileUrl);
const isDefaultOnline = this.isDefaultOnlineUrl(customTileUrl);

let tileUrl;
if (isOffline) {
// If it's a known online URL, force offline tiles from MBTiles
if (isDefaultOnline) {
tileUrl = "/api/v1/map/tiles/{z}/{x}/{y}.png";
} else if (isCustomLocal) {
// It's a local/mesh URL, allow it
tileUrl = customTileUrl;
} else if (customTileUrl !== defaultTileUrl) {
// It's a custom URL that isn't a known online one,
// assume it might be a local mesh server with a domain.
tileUrl = customTileUrl;
} else {
// Fallback to offline MBTiles
tileUrl = "/api/v1/map/tiles/{z}/{x}/{y}.png";
}
} else {
tileUrl = customTileUrl;
}

const source = new XYZ({
url: tileUrl,
crossOrigin: "anonymous",
});

// Track tile load errors to notify user if they appear to be offline
if (source && typeof source.on === "function") {
source.on("tileloaderror", () => {
if (!isOffline) {
this.tileErrorCount++;
if (this.tileErrorCount > 5) {
this.showOfflineHint = true;
// Reset count after showing hint to avoid multiple triggers
this.tileErrorCount = 0;
// Auto-hide hint after 30 seconds
setTimeout(() => {
this.showOfflineHint = false;
}, 30000);
}
}
});
}

const originalTileLoadFunction = source.getTileLoadFunction();

if (isOffline) {
source.setTileLoadFunction(async (tile, src) => {
try {
const response = await fetch(src);
if (!response.ok) {
if (response.status === 404) {
tile.setState(3);
return;
}
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
tile.getImage().src = url;
// Cleanup to prevent memory leaks
setTimeout(() => URL.revokeObjectURL(url), 10000);
} catch {
tile.setState(3);
}
});
} else {
source.setTileLoadFunction(async (tile, src) => {
if (!this.cachingEnabled) {
originalTileLoadFunction(tile, src);
return;
}

try {
const cached = await TileCache.getTile(src);
if (cached) {
const url = URL.createObjectURL(cached);
tile.getImage().src = url;
setTimeout(() => URL.revokeObjectURL(url), 10000);
return;
}

const response = await fetch(src);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
tile.getImage().src = url;
setTimeout(() => URL.revokeObjectURL(url), 10000);

// Background cache write to avoid blocking UI
TileCache.setTile(src, blob).catch(() => {});
} catch {
originalTileLoadFunction(tile, src);
}
});
}

return source;
},
async checkOfflineMap() {
try {
const response = await window.api.get("/api/v1/map/offline");
if (response.data && response.data.loaded !== false && Object.keys(response.data).length > 0) {
this.metadata = response.data;
this.hasOfflineMap = true;

if (this.offlineEnabled) {
this.updateMapSource();
}
} else {
this.hasOfflineMap = false;
this.metadata = null;
if (this.offlineEnabled) {
this.offlineEnabled = false;
this.updateMapSource();
}
}
} catch {
this.hasOfflineMap = false;
this.metadata = null;
if (this.offlineEnabled) {
this.offlineEnabled = false;
this.updateMapSource();
}
}
},
updateMapSource() {
if (!this.map) return;
const layers = this.map.getLayers();

// Find and replace the tile layer (first layer usually)
// or just clear and re-add everything correctly
layers.clear();

// 1. Tile layer
layers.push(
new TileLayer({
source: this.getTileSource(),
})
);

// 2. Draw layer
if (this.drawLayer) {
layers.push(this.drawLayer);
}

// 3. Marker layer
if (this.markerLayer) {
layers.push(this.markerLayer);
}
},
async toggleOffline(enabled) {
if (enabled && !this.hasOfflineMap) {
ToastUtils.error(this.$t("map.no_map_loaded"));
return;
}

this.tileErrorCount = 0;
this.showOfflineHint = false;

if (enabled) {
const defaultTileUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const defaultNominatimUrl = "https://nominatim.openstreetmap.org";

const isCustomTileLocal = this.isLocalUrl(this.tileServerUrl);
const isDefaultTileOnline = this.isDefaultOnlineUrl(this.tileServerUrl);
const hasCustomTile = this.tileServerUrl && this.tileServerUrl !== defaultTileUrl;

const isCustomNominatimLocal = this.isLocalUrl(this.nominatimApiUrl);
const isDefaultNominatimOnline = this.isDefaultOnlineUrl(this.nominatimApiUrl);
const hasCustomNominatim = this.nominatimApiUrl && this.nominatimApiUrl !== defaultNominatimUrl;

if (hasCustomTile && !isCustomTileLocal && !isDefaultTileOnline) {
const isAccessible = await this.checkApiConnection(this.tileServerUrl);
if (!isAccessible) {
ToastUtils.error(this.$t("map.custom_tile_server_unavailable"));
return;
}
}

if (hasCustomNominatim && !isCustomNominatimLocal && !isDefaultNominatimOnline) {
const isAccessible = await this.checkApiConnection(this.nominatimApiUrl);
if (!isAccessible) {
ToastUtils.error(this.$t("map.custom_nominatim_unavailable"));
return;
}
}
}

this.offlineEnabled = enabled;
if (enabled) {
this.isExportMode = false;
this.clearSearch();
}
this.updateMapSource();
await this.saveMapState();

// Persist setting
try {
await window.api.patch("/api/v1/config", {
map_offline_enabled: enabled,
});
} catch (e) {
console.error("Failed to save offline setting", e);
}
},
async toggleCaching(enabled) {
this.cachingEnabled = enabled;
this.tileErrorCount = 0;
this.showOfflineHint = false;
try {
await window.api.patch("/api/v1/config", {
map_tile_cache_enabled: enabled,
});
} catch (e) {
console.error("Failed to save caching setting", e);
}
},
toggleExportMode() {
this.isExportMode = !this.isExportMode;
if (!this.isExportMode) {
this.selectedBbox = null;
}
},
cancelExport() {
this.selectedBbox = null;
this.isExportMode = false;
},
async cancelActiveExport() {
if (!this.exportId) {
this.exportStatus = null;
return;
}
try {
await window.api.delete(`/api/v1/map/export/${this.exportId}`);
this.exportStatus = null;
this.exportId = null;
ToastUtils.success(this.$t("map.export_cancelled"));
} catch {
ToastUtils.error(this.$t("map.failed_cancel_export"));
}
},
async startExport() {
if (!this.selectedBbox) return;
this.isExporting = true;
try {
const response = await window.api.post("/api/v1/map/export", {
bbox: this.selectedBbox,
min_zoom: this.exportMinZoom,
max_zoom: this.exportMaxZoom,
name: `Map Export ${new Date().toLocaleString()}`,
});
this.exportId = response.data.export_id;
this.isExportMode = false;
this.selectedBbox = null;
this.pollExportStatus();
} catch {
ToastUtils.error(this.$t("map.failed_start_export"));
this.isExporting = false;
}
},
pollExportStatus() {
if (this.exportInterval) clearInterval(this.exportInterval);
this.exportInterval = setInterval(async () => {
try {
const response = await window.api.get(`/api/v1/map/export/${this.exportId}`);
this.exportStatus = response.data;
if (this.exportStatus.status === "completed" || this.exportStatus.status === "failed") {
clearInterval(this.exportInterval);
this.isExporting = false;
if (this.exportStatus.status === "completed") {
this.loadMBTilesList();
}
}
} catch {
clearInterval(this.exportInterval);
this.isExporting = false;
}
}, 2000);
},
lonToTile(lon, zoom) {
return Math.floor(((lon + 180) / 360) * Math.pow(2, zoom));
},
latToTile(lat, zoom) {
return Math.floor(
((1 - Math.log(Math.tan((lat * Math.PI) / 180) + 1 / Math.cos((lat * Math.PI) / 180)) / Math.PI) / 2) *
Math.pow(2, zoom)
);
},
async onFileSelected(event) {
const file = event.target.files[0];
if (!file) return;

if (!file.name.endsWith(".mbtiles")) {
ToastUtils.error(this.$t("map.select_mbtiles_error"));
return;
}

this.isUploading = true;
const formData = new FormData();
formData.append("file", file);

try {
const response = await window.api.post("/api/v1/map/offline", formData, {
headers: {
"Content-Type": "multipart/form-data",
},
});

this.metadata = response.data.metadata;
this.hasOfflineMap = true;
this.offlineEnabled = true;
await this.loadMBTilesList();
await this.checkOfflineMap();
this.updateMapSource();
ToastUtils.success(this.$t("map.upload_success"));

// If the map has bounds, we might want to fit to them
if (this.metadata.bounds) {
const bounds = this.metadata.bounds.split(",").map(parseFloat);
if (bounds.length === 4) {
const extent = [...fromLonLat([bounds[0], bounds[1]]), ...fromLonLat([bounds[2], bounds[3]])];
this.map.getView().fit(extent, { padding: [20, 20, 20, 20] });
}
}
} catch (e) {
const error = e.response?.data?.error || e.message;
ToastUtils.error(this.$t("map.upload_failed") + ": " + error);
} finally {
this.isUploading = false;
event.target.value = ""; // Reset input
}
},
async setAsDefaultView() {
if (!this.map) return;
const view = this.map.getView();
const center = toLonLat(view.getCenter());
const zoom = Math.round(view.getZoom());

try {
await window.api.patch("/api/v1/config", {
map_default_lat: center[1],
map_default_lon: center[0],
map_default_zoom: zoom,
});
ToastUtils.success(this.$t("map.view_saved"));
} catch {
ToastUtils.error(this.$t("map.failed_save_view"));
}
},
async clearCache() {
try {
await TileCache.clear();
ToastUtils.success(this.$t("map.cache_cleared"));
} catch {
ToastUtils.error(this.$t("map.failed_clear_cache"));
}
},
async saveTileServerUrl() {
try {
await window.api.patch("/api/v1/config", {
map_tile_server_url: this.tileServerUrl,
});
this.updateMapSource();
ToastUtils.success(this.$t("map.tile_server_saved"));
await this.saveMapState();
} catch {
ToastUtils.error(this.$t("map.failed_save_tile_server"));
}
},
setTileServer(type) {
this.tileErrorCount = 0;
this.showOfflineHint = false;
if (type === "osm") {
this.tileServerUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
} else if (type === "carto-dark") {
this.tileServerUrl = "https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png";
} else if (type === "carto-voyager") {
this.tileServerUrl = "https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png";
} else if (type === "carto-light") {
this.tileServerUrl = "https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";
}
this.saveTileServerUrl();
},
async saveNominatimApiUrl() {
try {
await window.api.patch("/api/v1/config", {
map_nominatim_api_url: this.nominatimApiUrl,
});
ToastUtils.success(this.$t("map.nominatim_api_saved"));
} catch {
ToastUtils.error(this.$t("map.failed_save_nominatim"));
}
},
checkOnboardingTooltip() {
const hasSeenOnboarding = localStorage.getItem("map_onboarding_seen");
if (!hasSeenOnboarding && !this.offlineEnabled) {
this.$nextTick(() => {
this.showOnboardingTooltip = true;
this.positionOnboardingTooltip();
});
}
},
positionOnboardingTooltip() {
this.$nextTick(() => {
if (!this.$refs.exportToolButton || !this.$refs.tooltipElement) return;

const exportButton = Array.isArray(this.$refs.exportToolButton)
? this.$refs.exportToolButton[0]
: this.$refs.exportToolButton;
const tooltip = this.$refs.tooltipElement;
const buttonRect = exportButton.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();

const isMobile = window.innerWidth Tff7b72< 640;
let tooltipLeft, tooltipTop;
let Te6edf3tooltipAboveButton = Ta5d6fffalse;

if (isMobile) {
Te6edf3tooltipLeft = Ta5d6ffwindow.innerWidth / 2 - tooltipRect.width / 2;
Te6edf3tooltipTop = Ta5d6ffbuttonRect.top - tooltipRect.height - 20;
Te6edf3tooltipAboveButton = Ta5d6fftrue;
if (tooltipTop < 10) {
Te6edf3tooltipTop = Ta5d6ffbuttonRect.bottom + 20;
Te6edf3tooltipAboveButton = Ta5d6fffalse;
}
} else {
Te6edf3tooltipLeft = Ta5d6ffbuttonRect.left - tooltipRect.width - 20;
Te6edf3tooltipTop = Ta5d6ffbuttonRect.top + buttonRect.height / 2 - tooltipRect.height / 2;
}

if (tooltipTop < 10) Te6edf3tooltipTop = Ta5d6ff10;
if (tooltipLeft < 10) Te6edf3tooltipLeft = Ta5d6ff10;
if (tooltipLeft + tooltipRect.width Tff7b72> window.innerWidth - 10) {
tooltipLeft = window.innerWidth - tooltipRect.width - 10;
}

this.tooltipStyle = {
left: `${tooltipLeft}px`,
top: `${tooltipTop}px`,
};

const buttonCenterY = buttonRect.top + buttonRect.height / 2;
const tooltipCenterX = tooltipLeft + tooltipRect.width / 2;
const tooltipCenterY = tooltipTop + tooltipRect.height / 2;

const arrowStartX = isMobile ? tooltipCenterX : tooltipLeft + tooltipRect.width;
const arrowStartY = isMobile
? tooltipAboveButton
? tooltipTop + tooltipRect.height
: tooltipTop
: tooltipCenterY;

const arrowEndX = buttonRect.left + buttonRect.width * 0.25;
const arrowEndY = buttonCenterY;

const minX = Math.min(arrowStartX, arrowEndX) - 20;
const maxX = Math.max(arrowStartX, arrowEndX) + 20;
const minY = Math.min(arrowStartY, arrowEndY) - 20;
const maxY = Math.max(arrowStartY, arrowEndY) + 20;

this.arrowSvgWidth = maxX - minX;
this.arrowSvgHeight = maxY - minY;

const adjustedStartX = arrowStartX - minX;
const adjustedStartY = arrowStartY - minY;
const adjustedEndX = arrowEndX - minX;
const adjustedEndY = arrowEndY - minY;

const controlX1 = adjustedStartX + (adjustedEndX - adjustedStartX) * 0.5;
const controlY1 = adjustedStartY + (adjustedEndY - adjustedStartY) * 0.3;
const controlX2 = adjustedStartX + (adjustedEndX - adjustedStartX) * 0.7;
const controlY2 = adjustedStartY + (adjustedEndY - adjustedStartY) * 0.7;

this.arrowPath = `M ${adjustedStartX} ${adjustedStartY} C ${controlX1} ${controlY1}, ${controlX2} ${controlY2}, ${adjustedEndX} ${adjustedEndY}`;

this.arrowStyle = {
left: `${minX}px`,
top: `${minY}px`,
};
});
},
dismissOnboardingTooltip() {
this.showOnboardingTooltip = false;
localStorage.setItem("map_onboarding_seen", "true");
},
onSearchInput() {
this.searchError = null;
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
},
async performSearch() {
if (!this.searchQuery || this.isSearching) return;

const defaultNominatimUrl = "https://nominatim.openstreetmap.org";
const isCustomLocal = this.isLocalUrl(this.nominatimApiUrl);
const isDefaultOnline = this.isDefaultOnlineUrl(this.nominatimApiUrl);

if (this.offlineEnabled) {
if (isCustomLocal || (!isDefaultOnline && this.nominatimApiUrl !== defaultNominatimUrl)) {
const isAccessible = await this.checkApiConnection(this.nominatimApiUrl);
if (!isAccessible) {
this.searchError = this.$t("map.search_offline_error");
return;
}
} else {
this.searchError = this.$t("map.search_offline_error");
return;
}
}

this.isSearching = true;
this.searchError = null;
this.searchResults = [];

try {
const apiUrl = this.nominatimApiUrl.endsWith("/")
? this.nominatimApiUrl.slice(0, -1)
: this.nominatimApiUrl;
const url = `${apiUrl}/search?format=jsonTe6edf3&q=${encodeURIComponent(this.searchQuery)}&limit=10&addressdetails=1`;

const response = await fetch(url, {
headers: {
"User-Agent": "ReticulumMeshChatX/1.0",
},
});

if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}

const data = await response.json();

if (Array.isArray(data) && data.length > 0) {
this.searchResults = data.map((item) => ({
display_name: item.display_name,
lat: parseFloat(item.lat),
lon: parseFloat(item.lon),
type: item.type || item.class || "",
boundingbox: item.boundingbox,
}));
} else {
this.searchError = this.$t("map.search_no_results");
}
} catch (e) {
console.error("Search error:", e);
if (e.message.includes("Failed to fetch") || e.message.includes("NetworkError")) {
this.searchError = this.$t("map.search_connection_error");
this.showOfflineHint = true;
} else {
this.searchError = this.$t("map.search_error") + ": " + e.message;
}
} finally {
this.isSearching = false;
}
},
selectSearchResult(result) {
if (!this.map) return;

const view = this.map.getView();
const center = fromLonLat([result.lon, result.lat]);

if (result.boundingbox && result.boundingbox.length === 4) {
const [minLat, maxLat, minLon, maxLon] = result.boundingbox.map(parseFloat);
const extent = [...fromLonLat([minLon, minLat]), ...fromLonLat([maxLon, maxLat])];
view.fit(extent, {
padding: [50, 50, 50, 50],
duration: 500,
});
} else {
view.animate({
center: center,
zoom: Math.max(view.getZoom(), 15),
duration: 500,
});
}

this.clearSearch();
},
clearSearch() {
this.searchQuery = "";
this.searchResults = [];
this.searchError = null;
this.isSearchFocused = false;
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
this.searchTimeout = null;
}
},
handleClickOutside(event) {
if (this.$refs.searchContainer && !this.$refs.searchContainer.contains(event.target)) {
this.isSearchFocused = false;
}
},
checkScreenSize() {
this.isMobileScreen = window.innerWidth Tff7b72< 640;
if (!this.isMobileScreen) {
Te6edf3this.isMobileSearchOpen = Ta5d6fffalse;
}
},
toggleMobileSearch() {
Te6edf3this.isMobileSearchOpen = Ta5d6ff!this.isMobileSearchOpen;
if (this.isMobileSearchOpen) {
this.$nextTick(() =Tff7b72> {
const input = this.$refs.searchContainer?.querySelector("input");
if (input) {
input.focus();
}
});
} else {
this.isSearchFocused = false;
}
},
async fetchPeers() {
if (!window.api) return;
try {
const response = await window.api.get("/api/v1/lxmf/conversations");
const peers = {};
for (const conv of response.data.conversations) {
peers[conv.destination_hash] = conv;
}
this.peers = peers;
} catch (e) {
console.error("Failed to fetch peers", e);
}
},

attachDrawPersistence() {
if (!this.drawSource) return;
const persist = () => this.saveMapState();
this.drawSource.on("addfeature", persist);
this.drawSource.on("removefeature", persist);
this.drawSource.on("changefeature", persist);
this.drawSource.on("clear", persist);
},

deleteSelectedFeature() {
if (this.selectedFeature && this.drawSource) {
this.clearMeasurementOverlay(this.selectedFeature);
this.drawSource.removeFeature(this.selectedFeature);
if (this.select) this.select.getFeatures().clear();
this.selectedFeature = null;
this.saveMapState();
}
},

// Drawing methods
toggleDraw(type) {
if (!this.map) return;
if (this.drawType === type && !this.isDrawing) {
this.stopDrawing();
return;
}

this.stopDrawing();
this.isMeasuring = false;
this.drawType = type;

if (type === "Select") {
if (this.select) this.select.setActive(true);
if (this.translate) this.translate.setActive(true);
if (this.modify) this.modify.setActive(true);
return;
}

// Disable selection/translation while drawing
if (this.select) this.select.setActive(false);
if (this.translate) this.translate.setActive(false);
if (this.modify) this.modify.setActive(false);

this.draw = new Draw({
source: this.drawSource,
type: type,
});

this.draw.on("drawstart", (evt) => {
this.isDrawing = true;
this.sketch = evt.feature;

// For LineString, Polygon, and Circle, show measure tooltip while drawing
if (type === "LineString" || type === "Polygon" || type === "Circle") {
this.createMeasureTooltip();
this._drawListener = this.sketch.getGeometry().on("change", (e) => {
const geom = e.target;
let output;
let tooltipCoord;
if (geom instanceof Polygon) {
output = this.formatArea(geom);
tooltipCoord = geom.getInteriorPoint().getCoordinates();
} else if (geom instanceof LineString) {
output = this.formatLength(geom);
tooltipCoord = geom.getLastCoordinate();
} else if (geom instanceof Circle) {
const radius = geom.getRadius();
const center = geom.getCenter();
// Calculate radius distance in projection (sphere-aware)
const edge = [center[0] + radius, center[1]];
const line = new LineString([center, edge]);
output = `Radius: ${this.formatLength(line)}`;
tooltipCoord = edge;
}
if (output) {
this.measureTooltipElement.innerHTML = output;
this.measureTooltip.setPosition(tooltipCoord);
}
});
}
});

this.draw.on("drawend", (evt) => {
this.isDrawing = false;
const feature = evt.feature;
feature.set("type", "draw"); // Tag as custom drawing for styling

// Clean up sketch listener and tooltips unless it was the Measure tool
if (this._drawListener) {
unByKey(this._drawListener);
this._drawListener = null;
}
this.sketch = null;

// Finalize measurement overlay for the drawn feature
this.finalizeMeasurementOverlay(feature);
this.cleanupMeasureTooltip();

// Re-enable select/translate/modify after drawing
if (this.select) this.select.setActive(true);
if (this.translate) this.translate.setActive(true);
if (this.modify) this.modify.setActive(true);
this.drawType = "Select";

setTimeout(() => this.saveMapState(), 100);
});

this.map.addInteraction(this.draw);
},

startEditingNote(feature) {
this.editingFeature = feature;
const telemetry = feature.get("telemetry");
this.noteText = telemetry ? telemetry.note || "" : feature.get("note") || "";
if (this.isMobileScreen) {
this.showNoteModal = true;
} else {
this.updateNoteOverlay();
}
},

updateNoteOverlay() {
if (!this.editingFeature || !this.map) return;
const geometry = this.editingFeature.getGeometry();
let coord;
if (geometry instanceof Point) {
coord = geometry.getCoordinates();
} else if (geometry instanceof LineString) {
coord = geometry.getCoordinateAt(0.5); // Middle of line
} else if (geometry instanceof Polygon) {
coord = geometry.getInteriorPoint().getCoordinates();
} else if (geometry instanceof Circle) {
coord = geometry.getCenter();
} else {
coord = this.map.getView().getCenter();
}
this.noteOverlay.setPosition(coord);
},

saveNote() {
if (this.editingFeature) {
const telemetry = this.editingFeature.get("telemetry");
if (telemetry) {
telemetry.note = this.noteText;
} else {
this.editingFeature.set("note", this.noteText);
}
this.saveMapState();
}
this.closeNoteEditor();
},

cancelNote() {
// If it's a new note (no text and just added), we might want to remove it
// but for now just close
this.closeNoteEditor();
},

closeNoteEditor() {
this.editingFeature = null;
this.noteText = "";
this.showNoteModal = false;
if (this.noteOverlay) {
this.noteOverlay.setPosition(undefined);
}
},

deleteNote() {
if (this.editingFeature) {
this.drawSource.removeFeature(this.editingFeature);
this.saveMapState();
}
this.closeNoteEditor();
},

// Measurement helpers
cleanupMeasureTooltip() {
if (this.measureTooltipElement && this.measureTooltipElement.parentNode) {
this.measureTooltipElement.parentNode.removeChild(this.measureTooltipElement);
}
if (this.measureTooltip) {
this.map.removeOverlay(this.measureTooltip);
}
this.measureTooltipElement = null;
this.measureTooltip = null;
},
getMeasurementForGeometry(geom) {
if (geom instanceof Polygon) {
return {
text: this.formatArea(geom),
coord: geom.getInteriorPoint().getCoordinates(),
};
}
if (geom instanceof LineString) {
return {
text: this.formatLength(geom),
coord: geom.getLastCoordinate(),
};
}
if (geom instanceof Circle) {
const center = geom.getCenter();
const edge = [center[0] + geom.getRadius(), center[1]];
const line = new LineString([center, edge]);
return {
text: `Radius: ${this.formatLength(line)}`,
coord: edge,
};
}
return null;
},
clearMeasurementOverlay(feature) {
const overlay = feature.get("_measureOverlay");
if (overlay) {
this.map.removeOverlay(overlay);
feature.unset("_measureOverlay", true);
}
},
finalizeMeasurementOverlay(feature) {
if (!this.map) return;
this.clearMeasurementOverlay(feature);
const geom = feature.getGeometry();
const measurement = this.getMeasurementForGeometry(geom);
if (!measurement) return;
const el = document.createElement("div");
el.className = "ol-tooltip ol-tooltip-static";
el.innerHTML = measurement.text;
const overlay = new Overlay({
element: el,
offset: [0, -7],
positioning: "bottom-center",
});
overlay.set("isMeasureTooltip", true);
this.map.addOverlay(overlay);
overlay.setPosition(measurement.coord);
feature.set("_measureOverlay", overlay);
},
rebuildMeasurementOverlays() {
if (!this.drawSource || !this.map) return;
// Remove all existing measure overlays
const overlays = this.map.getOverlays().getArray();
for (let i = overlays.length - 1; i >= 0; i--) {
const ov = overlays[i];
if (ov.get && ov.get("isMeasureTooltip")) {
this.map.removeOverlay(ov);
}
}
// Rebuild for all features
this.drawSource.getFeatures().forEach((f) => {
f.unset("_measureOverlay", true);
this.finalizeMeasurementOverlay(f);
});
},
serializeFeatures(features) {
return features.map((f) => {
const clone = f.clone();
clone.unset("_measureOverlay", true); // avoid circular refs
const geom = clone.getGeometry();
if (geom instanceof Circle) {
clone.setGeometry(fromCircle(geom, 128));
}
return clone;
});
},
// Context menu handlers
onContextMenu(evt) {
if (!this.map) return;
evt.preventDefault();
const pixel = this.map.getEventPixel(evt);
const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f);
this.contextMenuFeature = feature || null;
this.contextMenuCoord = toLonLat(this.map.getCoordinateFromPixel(pixel));
this.contextMenuPos = { x: evt.clientX, y: evt.clientY };
if (feature && this.select) {
this.select.getFeatures().clear();
this.select.getFeatures().push(feature);
this.selectedFeature = feature;
}
this.showContextMenu = true;
},
closeContextMenu() {
this.showContextMenu = false;
},
contextSelectFeature() {
if (!this.contextMenuFeature || !this.select || !this.translate) {
this.closeContextMenu();
return;
}
this.select.setActive(true);
this.translate.setActive(true);
this.modify?.setActive(true);
this.select.getFeatures().clear();
this.select.getFeatures().push(this.contextMenuFeature);
this.selectedFeature = this.contextMenuFeature;
this.drawType = "Select";
this.closeContextMenu();
},
contextDeleteFeature() {
if (this.contextMenuFeature && !this.contextMenuFeature.get("telemetry")) {
this.drawSource.removeFeature(this.contextMenuFeature);
this.saveMapState();
}
this.closeContextMenu();
},
contextAddNote() {
if (this.contextMenuFeature) {
this.startEditingNote(this.contextMenuFeature);
}
this.closeContextMenu();
},
async contextCopyCoords() {
if (!this.contextMenuCoord) {
this.closeContextMenu();
return;
}
const [lon, lat] = this.contextMenuCoord;
const text = `${lat.toFixed(6)}, ${lon.toFixed(6)}`;
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
ToastUtils.success(this.$t("map.copied_coordinates"));
} else {
ToastUtils.success(text);
}
} catch (e) {
console.error("Copy failed", e);
ToastUtils.warning(text);
}
this.closeContextMenu();
},
contextClearMap() {
this.clearDrawings();
this.closeContextMenu();
},
// Clear all overlays on escape/context close
handleGlobalClick() {
if (this.showContextMenu) {
this.closeContextMenu();
}
},

handleMapPointerMove(evt) {
if (!this.map) return;
const lonLat = toLonLat(evt.coordinate);
this.cursorCoords = [lonLat[0], lonLat[1]];
if (evt.dragging || this.isDrawing || this.isMeasuring) return;

const pixel = this.map.getEventPixel(evt.originalEvent);
const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f);

if (feature) {
const hasNote = feature.get("note") || (feature.get("telemetry") && feature.get("telemetry").note);
if (hasNote) {
this.hoveredFeature = feature;
} else {
this.hoveredFeature = null;
}

// Handle marker hover effects
const isMarker = feature.get("telemetry") || feature.get("discovered") || feature.get("cluster");
if (isMarker && this.hoveredMarker !== feature) {
const oldHovered = this.hoveredMarker;
this.hoveredMarker = feature;
// Trigger style refresh
feature.changed();
if (oldHovered) oldHovered.changed();
}

this.map.getTargetElement().style.cursor = "pointer";
} else {
this.hoveredFeature = null;
if (this.hoveredMarker) {
const oldHovered = this.hoveredMarker;
this.hoveredMarker = null;
oldHovered.changed();
}
this.map.getTargetElement().style.cursor = "";
}
},

handleMapClick(evt) {
if (this.isDrawing || this.isMeasuring) return;

const pixel = this.map.getEventPixel(evt.originalEvent);
const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f, {
layerFilter: (l) => l === this.drawLayer,
});

if (feature && feature.get("type") === "note") {
this.startEditingNote(feature);
} else {
this.closeNoteEditor();
}
},

stopDrawing() {
if (this.draw) {
this.map.removeInteraction(this.draw);
this.draw = null;
}
if (this.select) this.select.setActive(true);
if (this.translate) this.translate.setActive(true);
if (this.modify) this.modify.setActive(true);
this.drawType = null;
this.isDrawing = false;
this.stopMeasuring();
},

clearDrawings() {
if (confirm("Clear all drawings from the map?")) {
this.drawSource.clear();
// clear tooltips if any
const overlays = this.map.getOverlays().getArray();
for (let i = overlays.length - 1; i >= 0; i--) {
const overlay = overlays[i];
if (overlay.get("isMeasureTooltip")) {
this.map.removeOverlay(overlay);
}
}
this.saveMapState();
}
},

// Measurement methods
toggleMeasure() {
if (!this.map) return;
if (this.isMeasuring) {
this.stopMeasuring();
this.drawType = null;
return;
}

this.stopDrawing();
this.isMeasuring = true;
this.drawType = "LineString";

this.createMeasureTooltip();
this.createHelpTooltip();

this.draw = new Draw({
source: this.drawSource,
type: "LineString",
style: new Style({
fill: new Fill({
color: "rgba(255, 255, 255, 0.2)",
}),
stroke: new Stroke({
color: "rgba(0, 0, 0, 0.5)",
lineDash: [10, 10],
width: 2,
}),
image: new CircleStyle({
radius: 5,
stroke: new Stroke({
color: "rgba(0, 0, 0, 0.7)",
}),
fill: new Fill({
color: "rgba(255, 255, 255, 0.2)",
}),
}),
}),
});
this.map.addInteraction(this.draw);

let listener;
this.draw.on("drawstart", (evt) => {
this.sketch = evt.feature;
let tooltipCoord = evt.coordinate;

listener = this.sketch.getGeometry().on("change", (evt) => {
const geom = evt.target;
let output;
if (geom instanceof Polygon) {
output = this.formatArea(geom);
tooltipCoord = geom.getInteriorPoint().getCoordinates();
} else if (geom instanceof LineString) {
output = this.formatLength(geom);
tooltipCoord = geom.getLastCoordinate();
}
this.measureTooltipElement.innerHTML = output;
this.measureTooltip.setPosition(tooltipCoord);
});
});

this.draw.on("drawend", () => {
this.measureTooltipElement.className = "ol-tooltip ol-tooltip-static";
this.measureTooltip.setOffset([0, -7]);
this.sketch = null;
this.measureTooltipElement = null;
this.createMeasureTooltip();
unByKey(listener);
});

this.map.on("pointermove", this.pointerMoveHandler);
},

stopMeasuring() {
this.isMeasuring = false;
if (this.draw && this.map) {
this.map.removeInteraction(this.draw);
this.draw = null;
}
if (this.map) {
this.map.un("pointermove", this.pointerMoveHandler);
}
if (this.helpTooltip && this.map) {
this.map.removeOverlay(this.helpTooltip);
this.helpTooltip = null;
}
this.sketch = null;
},

pointerMoveHandler(evt) {
if (evt.dragging) return;
let helpMsg = "Click to start drawing";
if (this.sketch) {
helpMsg = "Click to continue drawing, double-click to finish";
}
this.helpTooltipElement.innerHTML = helpMsg;
this.helpTooltip.setPosition(evt.coordinate);
this.helpTooltipElement.classList.remove("hidden");
},

formatLength(line) {
const length = getLength(line);
let output;
let imperialOutput;

// Metric
if (length > 100) {
output = Math.round((length / 1000) * 100) / 100 + " km";
} else {
output = Math.round(length * 100) / 100 + " m";
}

// Imperial
const feet = length * 3.28084;
if (feet > 5280) {
const miles = length * 0.000621371;
imperialOutput = Math.round(miles * 100) / 100 + " mi";
} else {
imperialOutput = Math.round(feet * 100) / 100 + " ft";
}

return `${output}Tff7b72<brTff7b72/>Tff7b72<span Te6edf3class=Ta5d6ff"text-[10px] opacity-80"Tff7b72>${imperialOutput}Tff7b72</span>`;
},

formatArea(polygon) {
const area = getArea(polygon);
let output;
let imperialOutput;

// Metric
if (area > 10000) {
output = Math.round((area / 1000000) * 100) / 100 + " km²";
} else {
output = Math.round(area * 100) / 100 + " m²";
}

// Imperial
const sqFeet = area * 10.7639;
if (sqFeet > 27878400) {
// > 1 sq mile
const sqMiles = area * 0.000000386102;
imperialOutput = Math.round(sqMiles * 100) / 100 + " mi²";
} else {
imperialOutput = Math.round(sqFeet * 100) / 100 + " ft²";
}

return `${output}Tff7b72<brTff7b72/>Tff7b72<span Te6edf3class=Ta5d6ff"text-[10px] opacity-80"Tff7b72>${imperialOutput}Tff7b72</span>`;
},

createHelpTooltip() {
if (!this.map) return;
if (this.helpTooltipElement && this.helpTooltipElement.parentNode) {
this.helpTooltipElement.parentNode.removeChild(this.helpTooltipElement);
}
this.helpTooltipElement = document.createElement("div");
this.helpTooltipElement.className = "ol-tooltip hidden";
this.helpTooltip = new Overlay({
element: this.helpTooltipElement,
offset: [15, 0],
positioning: "center-left",
});
this.map.addOverlay(this.helpTooltip);
},

createMeasureTooltip() {
if (!this.map) return;
this.measureTooltipElement = document.createElement("div");
this.measureTooltipElement.className = "ol-tooltip ol-tooltip-measure";
this.measureTooltip = new Overlay({
element: this.measureTooltipElement,
offset: [0, -15],
positioning: "bottom-center",
stopEvent: false,
insertFirst: false,
});
this.measureTooltip.set("isMeasureTooltip", true);
this.map.addOverlay(this.measureTooltip);
},

// Drawing storage methods
async openLoadDrawingModal() {
this.showLoadDrawingModal = true;
this.isLoadingDrawings = true;
try {
const response = await window.api.get("/api/v1/map/drawings");
this.savedDrawings = response.data.drawings;
} catch {
ToastUtils.error(this.$t("map.failed_load_drawings"));
} finally {
this.isLoadingDrawings = false;
}
},

async saveDrawing() {
if (!this.newDrawingName.trim()) return;
if (!this.drawSource) {
ToastUtils.error(this.$t("map.not_initialized"));
return;
}

const format = new GeoJSON();
const features = this.serializeFeatures(this.drawSource.getFeatures());
const json = format.writeFeatures(features, {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
});

try {
await window.api.post("/api/v1/map/drawings", {
name: this.newDrawingName,
data: json,
});
ToastUtils.success(this.$t("map.drawing_saved"));
this.showSaveDrawingModal = false;
this.newDrawingName = "";
} catch {
ToastUtils.error(this.$t("map.failed_save_drawing"));
}
},

async loadDrawing(drawing) {
const format = new GeoJSON();
const features = format.readFeatures(drawing.data, {
dataProjection: "EPSG:4326",
featureProjection: "EPSG:3857",
});
this.drawSource.clear();
this.drawSource.addFeatures(features);
await this.saveMapState();
this.showLoadDrawingModal = false;
ToastUtils.success(`Loaded "${drawing.name}"`);
},

async deleteDrawing(drawing) {
if (!confirm(`Delete drawing "${drawing.name}"?`)) return;
try {
await window.api.delete(`/api/v1/map/drawings/${drawing.id}`);
this.savedDrawings = this.savedDrawings.filter((d) => d.id !== drawing.id);
ToastUtils.success(this.$t("map.deleted"));
} catch {
ToastUtils.error(this.$t("map.failed_delete"));
}
},

goToMyLocation() {
// Priority 1: Use manual location if configured
if (this.config?.location_source === "manual") {
const lat = parseFloat(this.config.location_manual_lat);
const lon = parseFloat(this.config.location_manual_lon);
if (!isNaN(lat) && !isNaN(lon)) {
this.map.getView().animate({
center: fromLonLat([lon, lat]),
zoom: 15,
duration: 1000,
});
return;
}
}

// Priority 2: Use telemetry data if available for our own hash
if (this.config && this.config.identity_hash) {
const myTelemetry = this.telemetryList.find((t) => t.destination_hash === this.config.identity_hash);
if (myTelemetry && myTelemetry.telemetry?.location) {
const loc = myTelemetry.telemetry.location;
this.map.getView().animate({
center: fromLonLat([loc.longitude, loc.latitude]),
zoom: 15,
duration: 1000,
});
return;
}
}

// Priority 2: Use browser geolocation if online or available
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => {
this.map.getView().animate({
center: fromLonLat([pos.coords.longitude, pos.coords.latitude]),
zoom: 15,
duration: 1000,
});
},
(err) => {
console.error("Geolocation failed", err);
ToastUtils.warning(this.$t("map.location_not_determined"));
}
);
} else {
ToastUtils.warning(this.$t("map.geolocation_not_supported"));
}
},
async fetchTelemetryMarkers() {
if (!window.api) return;
try {
const response = await window.api.get("/api/v1/telemetry/peers");
this.telemetryList = response.data.telemetry;
this.updateMarkers();
} catch (e) {
console.error("Failed to fetch telemetry", e);
}
},
dedupeTelemetryMarkersForMap(telemetryList) {
return dedupeTelemetryMarkersForMapHelper(telemetryList, this.peers || {});
},
dedupeDiscoveredMapNodes(nodes) {
return dedupeDiscoveredMapNodesHelper(nodes);
},
updateMarkers() {
if (!this.markerSource) return;
this.markerSource.clear();

const candidates = [];

for (const t of this.dedupeTelemetryMarkersForMap(this.telemetryList)) {
const loc = t.telemetry?.location;
if (!loc || loc.latitude === undefined || loc.longitude === undefined) continue;
const coord = fromLonLat([loc.longitude, loc.latitude]);
const feature = new Feature({
geometry: new Point(coord),
telemetry: t,
peer: this.peers[t.destination_hash],
originalCoord: coord,
});
candidates.push({ feature, coord, clusterable: true });
}

if (this.discoveredMarkers && this.discoveredMarkers.length > 0) {
for (const feature of this.discoveredMarkers) {
const coord = feature.get("originalCoord") || feature.getGeometry().getCoordinates();
if (!feature.get("originalCoord")) feature.set("originalCoord", coord);
candidates.push({ feature, coord, clusterable: true });
}
}

const view = this.map.getView();
const resolution = view && typeof view.getResolution === "function" ? view.getResolution() : 1;
const safeResolution = Number.isFinite(resolution) && resolution > 0 ? resolution : 1;
const clusterPixelDistance = 38;
const clusterCoordDistance = clusterPixelDistance * safeResolution;
const sqClusterCoordDistance = clusterCoordDistance * clusterCoordDistance;

const visited = new Array(candidates.length).fill(false);
for (let i = 0; i Tff7b72< candidates.length; i++) {
if (visited[i]) continue;
visited[i] = true;
const Te6edf3seed = Ta5d6ffcandidates[i];
const Te6edf3groupItems = Ta5d6ff[seed];

if (seed.clusterable) {
for (let Te6edf3j = Ta5d6ffi + 1; j < candidates.length; j++) {
if (visited[j] || !candidates[j].clusterable) continue;
const Te6edf3dx = Ta5d6ffcandidates[j].coord[0] - seed.coord[0];
const Te6edf3dy = Ta5d6ffcandidates[j].coord[1] - seed.coord[1];
if (dx * dx + dy * dy <= sqClusterCoordDistance) {
visited[j] = true;
groupItems.push(candidates[j]);
}
}
}

if (Te6edf3groupItems.length =Ta5d6ff== 1) {
const Te6edf3feature = Ta5d6ffseed.feature;
const Te6edf3originalCoord = Ta5d6fffeature.get("originalCoord") || seed.coord;
feature.setGeometry(new Point(originalCoord));
this.markerSource.addFeature(feature);
continue;
}

let Te6edf3cx = Ta5d6ff0;
let Te6edf3cy = Ta5d6ff0;
for (const item of groupItems) {
cx += item.coord[0];
cy += item.coord[1];
}
cx /= groupItems.length;
cy /= groupItems.length;

const Te6edf3clusterFeature = Ta5d6ffnew Feature({
geometry: new Point([cx, cy]),
cluster: true,
clusterCount: groupItems.length,
clusterItems: groupItems.map((g) =Tff7b72> g.feature),
originalCoord: [cx, cy],
});
this.markerSource.addFeature(clusterFeature);
}

if (this.queryMarker) {
const coord = this.queryMarker.get("originalCoord") || this.queryMarker.getGeometry().getCoordinates();
if (!this.queryMarker.get("originalCoord")) this.queryMarker.set("originalCoord", coord);
this.queryMarker.setGeometry(new Point(coord));
this.markerSource.addFeature(this.queryMarker);
}
},
createClusterStyle(count, isHovered) {
const cacheKey = `cluster-${count}-${isHovered ? "h" : "n"}`;
if (this.styleCache[cacheKey]) return this.styleCache[cacheKey];

const radius = isHovered ? 20 : 18;
const style = new Style({
image: new CircleStyle({
radius,
fill: new Fill({ color: "rgba(37, 99, 235, 0.92)" }),
stroke: new Stroke({ color: "#ffffff", width: 2 }),
}),
text: new Text({
text: String(count),
font: "bold 13px sans-serif",
fill: new Fill({ color: "#ffffff" }),
stroke: new Stroke({ color: "rgba(0,0,0,0.35)", width: 1 }),
}),
});
this.styleCache[cacheKey] = style;
return style;
},
buildClusterItems(feature) {
return buildClusterItemsHelper(feature);
},
/**
* Open the cluster details overlay and zoom the viewport to encompass
* the cluster's items. Always called when a user clicks a cluster
* marker on the map.
*/
openCluster(feature) {
if (!feature) return;
const items = this.buildClusterItems(feature);
this.selectedMarker = null;
this.selectedCluster = {
count: items.length || feature.get("clusterCount") || 0,
items,
};
this.zoomToCluster(feature);
},
closeClusterPanel() {
this.selectedCluster = null;
},
selectClusterItem(item) {
if (!item || !item.feature) return;
const view = this.map ? this.map.getView() : null;
const coord = item.coord;
if (view && coord && Array.isArray(coord) && coord.length >= 2) {
const currentZoom = (typeof view.getZoom === "function" && view.getZoom()) || 12;
if (typeof view.animate === "function") {
view.animate({
center: coord,
zoom: Math.min(currentZoom + 2, 19),
duration: 300,
});
}
}
this.onMarkerClick(item.feature);
this.selectedCluster = null;
},
extentDiagonal(extent) {
return computeExtentDiagonal(extent);
},
/**
* Smoothly zoom the viewport to fit a cluster's contents. Handles the
* tricky cases that previously made clustering feel "weird":
* - all items at the same coordinate -> animate-zoom to that point
* - cluster spans less than ~1px -> treat as point
* - cluster fits in the current view -> still zoom in (don't sit
* at the same zoom level after a click)
* - very large clusters -> respect a sensible maxZoom
*/
zoomToCluster(feature) {
if (!feature || !this.map) return;
const items = feature.get("clusterItems") || [];
if (items.length === 0) return;

const view = this.map.getView();
const currentZoom = (typeof view.getZoom === "function" && view.getZoom()) || 12;
const viewMaxZoom = typeof view.getMaxZoom === "function" ? view.getMaxZoom() : 19;
const safeMaxZoom = Number.isFinite(viewMaxZoom) ? viewMaxZoom : 19;

const extent = createEmptyExtent();
const coords = [];
for (const item of items) {
const coord =
item.get("originalCoord") ||
(typeof item.getGeometry === "function" && item.getGeometry()
? item.getGeometry().getCoordinates()
: null);
if (coord && Array.isArray(coord) && coord.length >= 2) {
coords.push(coord);
extendCoordinate(extent, coord);
}
}

if (coords.length === 0) return;

const resolution = typeof view.getResolution === "function" ? view.getResolution() : 1;
const safeResolution = Number.isFinite(resolution) && resolution > 0 ? resolution : 1;
const diagonal = this.extentDiagonal(extent);
const isDegenerate = isExtentEmpty(extent) || diagonal <= safeResolution;

if (isDegenerate) {
let cx = 0;
let cy = 0;
for (const c of coords) {
cx += c[0];
cy += c[1];
}
cx /= coords.length;
cy /= coords.length;
const targetZoom = Math.min(currentZoom + 4, safeMaxZoom);
if (typeof view.animate === "function") {
view.animate({ center: [cx, cy], zoom: targetZoom, duration: 400 });
} else {
if (typeof view.setCenter === "function") view.setCenter([cx, cy]);
if (typeof view.setZoom === "function") view.setZoom(targetZoom);
}
return;
}

if (typeof view.fit === "function") {
view.fit(extent, {
padding: [80, 80, 80, 80],
maxZoom: Math.min(currentZoom + 3, safeMaxZoom),
duration: 400,
});
}

this.$nextTick(() => {
const newZoom = (typeof view.getZoom === "function" && view.getZoom()) || currentZoom;
if (newZoom <= currentZoom + 0.01) {
const targetZoom = Math.min(currentZoom + 1, safeMaxZoom);
if (typeof view.animate === "function") {
view.animate({ zoom: targetZoom, duration: 250 });
} else if (typeof view.setZoom === "function") {
view.setZoom(targetZoom);
}
}
});
},
createMarkerStyle({ iconColor, bgColor, label, isStale, iconPath, scale = 0.6, isTracking = false }) {
const cacheKey = `${iconColor}-${bgColor}-${label}-${isStale}-${iconPath || "default"}-${scale}-${isTracking}`;
if (this.styleCache[cacheKey]) return this.styleCache[cacheKey];

const markerFill = isStale ? "#d1d5db" : bgColor;
const markerStroke = isStale ? "#9ca3af" : iconColor;
const path =
iconPath ||
"M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7Zm0 11a2 2 0 1 1 0-4 2 2 0 0 1 0 4Z";

const baseSize = isTracking ? 32 : 24;
const renderSize = baseSize * 2;

let svg = "";
if (isTracking) {
svg = `Tff7b72<svg Te6edf3xmlns=Ta5d6ff"http://www.w3.org/2000/svg" Te6edf3width=Ta5d6ff"${renderSize}" Te6edf3height=Ta5d6ff"${renderSize}" Te6edf3viewBox=Ta5d6ff"0 0 32 32"Tff7b72>
Tff7b72<circle Te6edf3cx=Ta5d6ff"16" Te6edf3cy=Ta5d6ff"16" Te6edf3r=Ta5d6ff"10" Te6edf3fill=Ta5d6ff"none" Te6edf3stroke=Ta5d6ff"#3b82f6" Te6edf3stroke-width=Ta5d6ff"2"Tff7b72>
Tff7b72<animate Te6edf3attributeName=Ta5d6ff"r" Te6edf3from=Ta5d6ff"10" Te6edf3to=Ta5d6ff"15" Te6edf3dur=Ta5d6ff"1.5s" Te6edf3repeatCount=Ta5d6ff"indefinite" Tff7b72/>
Tff7b72<animate Te6edf3attributeName=Ta5d6ff"stroke-opacity" Te6edf3from=Ta5d6ff"1" Te6edf3to=Ta5d6ff"0" Te6edf3dur=Ta5d6ff"1.5s" Te6edf3repeatCount=Ta5d6ff"indefinite" Tff7b72/>
Tff7b72</circle>
Tff7b72<circle Te6edf3cx=Ta5d6ff"16" Te6edf3cy=Ta5d6ff"16" Te6edf3r=Ta5d6ff"10" Te6edf3fill=Ta5d6ff"#3b82f6" Te6edf3fill-opacity=Ta5d6ff"0.2"Tff7b72>
Tff7b72<animate Te6edf3attributeName=Ta5d6ff"r" Te6edf3from=Ta5d6ff"8" Te6edf3to=Ta5d6ff"12" Te6edf3dur=Ta5d6ff"1.5s" Te6edf3repeatCount=Ta5d6ff"indefinite" Tff7b72/>
Tff7b72</circle>
Tff7b72<g Te6edf3transform=Ta5d6ff"translate(4,4)"Tff7b72>
Tff7b72<path Te6edf3d=Ta5d6ff"${path}" Te6edf3fill=Ta5d6ff"${markerFill}" Te6edf3stroke=Ta5d6ff"${markerStroke}" Te6edf3stroke-width=Ta5d6ff"1.5"Tff7b72/>
Tff7b72</g>
Tff7b72</svg>`;
} else {
svg = `Tff7b72<svg Te6edf3xmlns=Ta5d6ff"http://www.w3.org/2000/svg" Te6edf3width=Ta5d6ff"${renderSize}" Te6edf3height=Ta5d6ff"${renderSize}" Te6edf3viewBox=Ta5d6ff"0 0 24 24"Tff7b72>Tff7b72<path Te6edf3d=Ta5d6ff"${path}" Te6edf3fill=Ta5d6ff"${markerFill}" Te6edf3stroke=Ta5d6ff"${markerStroke}" Te6edf3stroke-width=Ta5d6ff"1.5"Tff7b72/>Tff7b72</svg>`;
}

const src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svg)));

const displayHeight = renderSize * scale;
const labelOffset = -(displayHeight + 6);

const style = new Style({
image: new Icon({
src: src,
anchor: [0.5, 1],
scale: scale,
imgSize: [renderSize, renderSize],
}),
text: new Text({
text: label,
offsetY: labelOffset,
font: "bold 12px sans-serif",
fill: new Fill({ color: isStale ? "#6b7280" : "#111827" }),
stroke: new Stroke({ color: "#ffffff", width: 3 }),
}),
});

this.styleCache[cacheKey] = style;
return style;
},
onMarkerClick(feature) {
this.selectedMarker = {
telemetry: feature.get("telemetry"),
peer: feature.get("peer"),
discovered: feature.get("discovered"),
};

// draw path for telemetry markers
if (this.selectedMarker.telemetry) {
this.drawTelemetryPath(this.selectedMarker.telemetry.destination_hash);
} else {
this.clearTelemetryPath();
}
},
async drawTelemetryPath(hash) {
this.clearTelemetryPath();
try {
const response = await window.api.get(`/api/v1/telemetry/history/${hash}?limit=50`);
const history = response.data.telemetry;
if (!history || history.length Tff7b72< 2) return;

// collect coordinates
const Te6edf3coords = Ta5d6ff[];
for (const entry of history) {
const Te6edf3loc = Ta5d6ffentry.telemetry?.location;
if (loc && loc.latitude !== undefined && loc.longitude !== undefined) {
coords.push(fromLonLat([loc.longitude, loc.latitude]));
}
}

if (coords.length < 2) return;

// create line feature
const Te6edf3line = Ta5d6ffnew LineString(coords);
const Te6edf3feature = Ta5d6ffnew Feature({
geometry: line,
type: "history_trail",
});

if (this.historySource) {
this.historySource.addFeature(feature);
}
} catch (e) {
console.error("Failed to draw telemetry path", e);
}
},
clearTelemetryPath() {
if (this.historySource) {
this.historySource.clear();
}
},
async onWebsocketMessage(message) {
const Te6edf3json = Ta5d6ffJSON.parse(message.data);
if (Te6edf3json.type =Ta5d6ff== "lxmf.telemetry") {
// Find and update or add to telemetryList
const Te6edf3index = Ta5d6ffthis.telemetryList.findIndex((t) =Tff7b72> t.destination_hash === json.destination_hash);
const oldEntry = index !== -1 ? this.telemetryList[index] : null;
const entry = {
destination_hash: json.destination_hash,
timestamp: json.timestamp,
telemetry: json.telemetry,
updated_at: new Date().toISOString(),
is_tracking:
json.is_tracking !== undefined ? json.is_tracking : oldEntry ? oldEntry.is_tracking : false,
physical_link: json.physical_link || oldEntry?.physical_link,
};

if (index !== -1) {
this.telemetryList.splice(index, 1, entry);
} else {
this.telemetryList.push(entry);
}

// Show notification for tracked peers
if (entry.telemetry?.location) {
const peer = this.peers[json.destination_hash];
const name = peer?.display_name || json.destination_hash.substring(0, 8);
const isTracked = this.telemetryList.find(
(t) => t.destination_hash === json.destination_hash
)?.is_tracking;

if (isTracked) {
ToastUtils.info(
`Live update: ${name} is at ${entry.telemetry.location.latitude.toFixed(4)}, ${entry.telemetry.location.longitude.toFixed(4)}`
);
}

// Update trail if this marker is currently selected
if (this.selectedMarker?.telemetry?.destination_hash === json.destination_hash) {
this.drawTelemetryPath(json.destination_hash);
}
}

this.updateMarkers();
}
},
formatTimestamp(ts) {
return new Date(ts * 1000).toLocaleString();
},
getMdiPath(iconName) {
if (!iconName) return null;
// same logic as MaterialDesignIcon.vue
const mdiName =
"mdi" +
iconName
.split("-")
.filter((word) => word.length > 0)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join("");
return mdi[mdiName] || null;
},
openChat(hash) {
this.$router.push({
name: "messages",
params: { destinationHash: hash },
});
},
async toggleTracking(hash) {
try {
const response = await window.api.post(`/api/v1/telemetry/tracking/${hash}/toggle`, {
is_tracking: this.selectedMarker.telemetry.is_tracking ? false : true,
});
if (this.selectedMarker && this.selectedMarker.telemetry.destination_hash === hash) {
this.selectedMarker.telemetry.is_tracking = response.data.is_tracking;
}
// Also update in telemetryList
const t = this.telemetryList.find((t) => t.destination_hash === hash);
if (t) t.is_tracking = response.data.is_tracking;

ToastUtils.success(response.data.is_tracking ? "Live tracking enabled" : "Live tracking disabled");
} catch (e) {
console.error("Failed to toggle tracking", e);
ToastUtils.error("Failed to update tracking status");
}
},
async toggleDiscoveredNodes() {
if (this.discoveredVisible) {
this.hideDiscoveredNodes();
return;
}
await this.mapDiscoveredNodes();
},
hideDiscoveredNodes() {
this.discoveredMarkers = [];
this.discoveredVisible = false;
if (this.selectedMarker?.discovered) {
this.selectedMarker = null;
this.clearTelemetryPath();
}
this.updateMarkers();
},
getDiscoveredIconName(node) {
return getDiscoveredIconNameHelper(node);
},
async mapDiscoveredNodes() {
try {
const response = await window.api.get("/api/v1/reticulum/discovered-interfaces");
const discovered = response.data?.interfaces ?? [];
const nodesWithLoc = discovered.filter((n) => n.latitude != null && n.longitude != null);
const nodesDeduped = this.dedupeDiscoveredMapNodes(nodesWithLoc);

if (nodesDeduped.length === 0) {
ToastUtils.info(this.$t("map.no_nodes_location"));
return;
}

const extent = createEmptyExtent();
this.discoveredMarkers = [];

for (const node of nodesDeduped) {
const coord = fromLonLat([node.longitude, node.latitude]);
extendExtent(extent, coord);

const feature = new Feature({
geometry: new Point(coord),
originalCoord: coord,
discovered: node,
});
this.discoveredMarkers.push(feature);
}

this.discoveredVisible = true;
this.updateMarkers();

if (!isExtentEmpty(extent)) {
this.map.getView().fit(extent, {
padding: [100, 100, 100, 100],
maxZoom: 12,
duration: 1000,
});
}

ToastUtils.success(`Mapped ${nodesDeduped.length} discovered nodes`);
} catch (e) {
console.error("Failed to map discovered nodes", e);
ToastUtils.error(this.$t("map.failed_fetch_nodes"));
}
},
openMapPopout() {
const url = `${window.location.origin}${window.location.pathname}#/popout/map`;
window.open(url, "_blank", "width=960,height=720,noopener");
},
getHashPopoutValue() {
const hash = window.location.hash || "";
const match = hash.match(/popout=([^Te6edf3&]+)/);
return match ? decodeURIComponent(match[1]) : null;
},
},
};
Tff7b72</script>

Tff7b72<style scopedTff7b72>
/* Ensure map takes full space */
:deep(.ol-viewport) {
border-radius: inherit;
}

.cursor-crosshair {
cursor: crosshair !important;
}

:deep(.ol-tooltip) {
position: relative;
background: rgba(0, 0, 0, 0.7);
border-radius: 4px;
color: white;
padding: 4px 8px;
opacity: 0.7;
font-size: 12px;
cursor: default;
user-select: none;
text-align: center;
line-height: 1.2;
}
:deep(.ol-tooltip-measure) {
opacity: 1;
font-weight: bold;
}
:deep(.ol-tooltip-static) {
background-color: #3b82f6;
color: white;
border: 1px solid white;
}
:deep(.ol-tooltip-measure:before),
:deep(.ol-tooltip-static:before) {
border-top: 6px solid rgba(0, 0, 0, 0.7);
border-right: 6px solid transparent;
border-left: 6px solid transparent;
content: "";
position: absolute;
bottom: -6px;
margin-left: -7px;
left: 50%;
}
:deep(.ol-tooltip-static:before) {
border-top-color: #3b82f6;
}

@keyframes slide-up {
from {
transform: translateY(100%);
}
to {
transform: translateY(0);
}
}

@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

.animate-slide-up {
animation: slide-up 0.3s ease-out;
}

.animate-fade-in {
animation: fade-in 0.3s ease-out;
}

.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}

.fade-enter-from,
.fade-leave-to {
opacity: 0;
}

@media (max-width: 639px) {
:deep(.ol-zoom) {
left: auto;
right: 0.75rem;
top: auto;
bottom: 0.75rem;
z-index: 12;
}
}
Tff7b72</style>

Served by rngit 1.5.2 - Generated in 0.14s